adblock-fast: update to 1.2.2-r6
authorStan Grishin <redacted>
Tue, 24 Feb 2026 20:56:38 +0000 (20:56 +0000)
committerStan Grishin <redacted>
Fri, 27 Feb 2026 01:00:05 +0000 (17:00 -0800)
Update adblock-fast from 1.2.1-r7 to 1.2.2-r6. This is a major
architectural rewrite that ports the core business logic from a ~2,700-line
monolithic shell script (`/etc/init.d/adblock-fast`) to a ~2,850-line ucode
module (`/lib/adblock-fast/adblock-fast.uc`), reducing the init script to a
thin ~130-line procd wrapper. The rewrite also introduces a comprehensive
test suite and adds the AGPL-3.0-or-later LICENSE file.

---

- **36 files changed**, +5,787 / -2,836 lines (net +2,951)
- **1 commit**: `0263b2b` — `adblock-fast: update to 1.2.2-r6`

---

The previous implementation embedded all business logic (download pipeline,
domain processing, resolver configuration, status reporting, caching)
inside the init.d script as a ~2,700-line POSIX shell script. This made the
code difficult to test, maintain, and extend. Shell limitations (no native
data structures, reliance on subshell `eval`, global namespace pollution)
also introduced fragility and performance overhead from repeated subprocess
spawning for UCI/ubus operations.

```
/etc/init.d/adblock-fast          (131 lines) — Thin procd wrapper
/lib/adblock-fast/adblock-fast.uc (2849 lines) — Core logic (ucode)
/lib/adblock-fast/cli.uc          (95 lines)  — CLI action dispatcher
```

The init script now delegates all operations to the ucode module via:
```sh
readonly _ucode="ucode -S -L /lib/${packageName} /lib/${packageName}/cli.uc --"
```

The CLI dispatcher (`cli.uc`) maps init script actions (start, stop,
status, allow, check, pause, etc.) to the module's exported functions.
The init script retains only procd lifecycle glue (`start_service`,
`stop_service`, `service_triggers`, `service_data`) and UCI validation
schemas.

1. **Native UCI/ubus bindings** — Direct `cursor()` and `connect()` calls
   replace subprocess-heavy `uci get/set` and `jsonfilter` pipelines
2. **Proper data structures** — Objects and arrays for config, status
   tracking, DNS mode definitions; no more string-concatenation state
   management
3. **Streaming I/O** — 64KB chunked file reads for blocklist processing
   instead of loading entire files into memory via pipes
4. **Memoized environment detection** — Platform capabilities (installed
   resolvers, ipset/nftset support, downloader detection) cached on first
   call
5. **Centralized trigger logic** — Config diff comparison
   (`adb_config_cache()`) determines download/restart/skip in one place
6. **Testable** — Module exports enable direct unit testing without mocking
   an entire init system

---

- `+ucode` — ucode interpreter runtime
- `+ucode-mod-fs` — Filesystem operations (readfile, writefile, popen,
  stat, etc.)
- `+ucode-mod-uci` — Native UCI cursor API
- `+ucode-mod-ubus` — Native ubus RPC API

- `+jshn` — No longer needed (was used for JSON parsing in shell)

- URL updated from `github.com/stangri/adblock-fast/` to
  `github.com/mossdef-org/adblock-fast/`
- Install target now installs `/lib/adblock-fast/adblock-fast.uc` and
  `/lib/adblock-fast/cli.uc` alongside the init script
- Version stamp now patches the ucode module
  (`version:` field) instead of init script (`PKG_VERSION` variable)
- `postinst` script removed (service enable handled elsewhere)
- `prerm` script simplified: only purges cache, no longer
  stops service or removes rc.d symlinks (handled by procd)

---

The module supports all existing DNS resolver integrations through a
unified `dns_modes{}` configuration map. Each mode defines output file
paths, gzip cache names, sed format/parse filters, and grep patterns:

| Mode                 | Output Format                                    |
|----------------------|--------------------------------------------------|
| `dnsmasq.addnhosts`  | `127.0.0.1 domain` (+ `:: domain` with IPv6)    |
| `dnsmasq.conf`       | `local=/domain/`                                 |
| `dnsmasq.ipset`      | `ipset=/domain/adb`                              |
| `dnsmasq.nftset`     | `nftset=/domain/4#inet#fw4#adb4[,6#...]`         |
| `dnsmasq.servers`    | `server=/domain/` (block) / `server=/domain/#` (allow) |
| `smartdns.domainset` | Raw domain (with smartdns conf wrapper)          |
| `smartdns.ipset`     | Raw domain (with smartdns ipset conf)            |
| `smartdns.nftset`    | Raw domain (with smartdns nftset conf)           |
| `unbound.adb_list`   | `local-zone: "domain." always_nxdomain`          |

The download pipeline auto-detects blocklist format from content:

| Format       | Detection                           | Example                    |
|--------------|-------------------------------------|----------------------------|
| AdBlock Plus | `[Adblock Plus]` header / `^||`     | `\|\|example.com^`         |
| dnsmasq      | `^server=`                          | `server=/example.com/`     |
| dnsmasq2     | `^local=`                           | `local=/example.com/`      |
| dnsmasq3     | `^address=`                         | `address=/example.com/0.0.0.0` |
| hosts        | `^0.0.0.0\s` or `^127.0.0.1\s`     | `0.0.0.0 example.com`     |
| domains      | (fallback — plain domain list)      | `example.com`              |

```
For each file_url UCI section:
  → Download URL (curl with retries, timeout, optional max-file-size)
  → Auto-detect format → Apply format-specific sed filter → Extract domains
  → Append to accumulator (blocked or allowed)

Merge phase:
  → sort -u (deduplicate)
  → Subdomain optimization (awk label-reverse → sort → dedup → reverse)
  → Remove allowed domains (sed -f generated_script)
  → Inject canary domains (iCloud Private Relay, Mozilla DoH)
  → Inject manually blocked_domain entries from config
  → Format for target DNS resolver
  → Optional validity check (remove malformed entries)
  → Atomic rename to output file

Resolver phase:
  → Update resolver config (UCI: addnhosts, conf-dir, server files)
  → Sanity check (dnsmasq --test)
  → Restart resolver service
  → Heartbeat probe (resolve canary domain to verify blocking)
  → Revert on failure
```

| Function              | Purpose                                              |
|-----------------------|------------------------------------------------------|
| `start(args)`         | Main lifecycle: download, restore from cache, or restart |
| `stop()`              | Disable blocking, flush kernel state, cleanup        |
| `status_service()`    | Report status to syslog/ubus                         |
| `allow(domain)`       | Whitelist domain in live blocklist + UCI config       |
| `check(pattern)`      | Search current blocklist for domain                  |
| `check_tld()`         | Detect TLD entries (sanity check)                    |
| `check_leading_dot()` | Detect leading-dot errors                            |
| `check_lists(domain)` | Search upstream list URLs for domain                 |
| `dl()`                | Force re-download all lists                          |
| `killcache()`         | Purge all cached files                               |
| `pause(seconds)`      | Temporarily disable blocking                         |
| `show_blocklist()`    | Output parsed blocklist to stdout                    |
| `sizes()`             | Fetch/display configured blocklist file sizes        |
| `get_init_status()`   | Full service state for UI/RPC clients                |
| `get_init_list()`     | Enabled/disabled status                              |
| `get_platform_support()` | Detect installed resolvers and features           |
| `get_file_url_filesizes()` | Return cached/live URL metadata                |

- 40+ localized message codes (e.g., `errorDownloadingList`,
  `errorConfigValidationFail`, `warningSanityCheckTLD`)
- Errors/warnings accumulated in `status_data{}` arrays
- Synced atomically to ubus service data for UI consumption
- Status states: `statusSuccess`, `statusFail`, `statusDownloading`,
  `statusProcessing`, `statusRestarting`, `statusPaused`

---

The init script (`/etc/init.d/adblock-fast`) is reduced from ~2,700 to ~130
lines. It now serves exclusively as a procd service wrapper:

- **procd lifecycle**: `start_service()` calls ucode `start`, captures
  shell output for `service_data()`; `stop_service()` calls ucode `stop`
- **Service triggers**: WAN interface triggers, config change triggers, UCI
  validation (unchanged from previous version)
- **Extra commands**: `allow`, `check`, `check_tld`, `check_leading_dot`,
  `check_lists`, `dl`, `killcache`, `pause`, `show_blocklist`, `sizes`,
  `version` — all delegate directly to ucode CLI dispatcher
- **procd data bridge**: `emit_procd_shell()` in ucode generates shell
  statements that the init script `eval`s for `service_data()` and
  `service_stopped()`/`service_started()` hooks (firewall restart flag)

---

The `90-adblock-fast` uci-defaults script is simplified from 181 to 65
lines:

- **Removed**: Entire `simple-adblock` migration path (config, cache files,
  URL lists). This migration was for the initial transition from
  simple-adblock to adblock-fast and is no longer needed.
- **Retained**: List name migration (adds `name` option to `file_url`
  sections that lack one, using pristine default config as reference),
  config key renames (`debug` → `debug_init_script`, `proc_debug` →
  `debug_performance`, `sanity_check` → `dnsmasq_sanity_check`)
- **Simplified**: Uses direct `uci` commands instead of sourcing the init
  script for `uci_get`/`uci_set` helpers. Pristine config lookup now
  supports both apk (`.apk-new`) and opkg (`-opkg`) package manager
  conventions.

---

A full test suite is added in `net/adblock-fast/tests/` (16 new files,
~1,800 lines) mock-and-expect pattern.

- **Module patching**: Converts ES6 imports to CommonJS requires, redirects
  hardcoded system paths to temp directories for isolation
- **Resolver stubs**: Mock binaries for dnsmasq (v2.89), smartdns, unbound,
  ipset, nft, resolveip
- **Test case format**: Markup-based (`-- Testcase --`,
  `-- Environment --`, `-- Expect stdout --`, `-- File path --`) with
  support for inline test data and per-test environment overrides
- **Assertion model**: Compares stdout, stderr, and exit code against
  expected values using `diff -u`
- **Shell validation**: Syntax-checks init.d and uci-defaults scripts via
  `sh -n`
- **Automatic cleanup**: Trap-based temp directory removal

**UCI Mock** (`tests/lib/mocklib/uci.uc`):
- Full `cursor()` interface: `load`, `get`, `get_all`, `foreach`, `set`,
  `delete`, `list_add`, `list_remove`, `commit`, `changes`
- Loads JSON fixtures from `tests/mocks/uci/` (adblock-fast, dhcp, network,
  smartdns, unbound configs)
- Supports `@type[index]` extended section addressing

**ubus Mock** (`tests/lib/mocklib/ubus.uc`):
- `connect()` → `call(object, method, args)` with signature-based fixture
  lookup
- Fixtures in `tests/mocks/ubus/` (system info, network interface
  dump/status, dnsmasq service list)

**System Call Interception** (`tests/lib/mocklib.uc`):
- Blocks service operations: `/etc/init.d/*`, `logger`, `sleep`,
  `dnsmasq --test`
- Passes through data processing: `sed`, `sort`, `grep`, `awk`
- Fixed timestamp (`1615382640`) for reproducible output
- Null `getenv()` for environment isolation

**01_pipeline** — Data processing pipeline (9 tests):
1. `01_all_dns_modes` — Verifies all 9 DNS output modes produce valid,
   deduplicated output (~162-165 domains from 2 input lists)
2. `02_input_format_detection` — Validates auto-detection of domains,
   hosts, AdBlock Plus, and dnsmasq input formats
3. `03_subdomain_dedup` — Confirms parent domains retained, child
   subdomains removed (e.g., blocks `example.com`, skips `sub.example.com`)
4. `04_allowed_domains` — Verifies `allowed_domain` config removes domains
   from output while preserving others
5. `05_canary_domains` — Confirms iCloud Private Relay and Mozilla DoH
   canary domain injection when enabled
6. `06_servers_mode_allow` — Validates dnsmasq.servers mode prepends
   explicit allow entries (`server=/domain/#` format)
7. `07_ipv6_addnhosts` — Verifies dual-stack output (both `127.0.0.1` and
   `::` entries) in addnhosts mode with IPv6 enabled
8. `08_ipv6_nftset` — Confirms nftset mode includes IPv6 set references
   (`4#inet#fw4#adb4,6#inet#fw4#adb6`) when IPv6 enabled
9. `09_unbound_header` — Validates `server:` header line prepended in
   unbound output mode

**02_config** — Configuration handling (1 test):
1. `01_blocked_domain_injection` — Verifies `blocked_domain` config entries
   appear in output

**03_functional** — CLI command tests (2 tests):
1. `01_check_domain` — Tests `check()` correctly identifies blocked vs.
   unblocked domains with appropriate output messages
2. `02_show_blocklist` — Tests `show_blocklist()` outputs parsed domain
   list (162 domains, correct format)

5 curated test data files with ~160+ unique test domains across multiple
formats (plain domains, hosts, AdBlock Plus, dnsmasq), including:
- Valid tracking/ad domains for positive matching
- Overlapping domains across files for deduplication testing
- Parent/child domain pairs for subdomain optimization testing
- Invalid entries (IPs, malformed, special chars) for filter robustness
- Mock UCI/ubus fixtures simulating a standard OpenWrt environment
  (512MB RAM, WAN interface up, dnsmasq running)

---

Adds the full AGPL-3.0-or-later license text (661 lines), matching the
`PKG_LICENSE` field already declared in the Makefile.

---

- Package compat bumped from `11` to `13` (in the ucode module's
  `pkg.compat` constant), reflecting the architectural change
- All existing UCI configuration options preserved (same validation schema)
- All existing extra_commands preserved (same CLI interface)
- All existing DNS resolver modes preserved (same output formats)
- procd service triggers and config triggers unchanged
- `simple-adblock` migration path removed from uci-defaults (obsolete)

---

```sh
cd net/adblock-fast/tests && sh run_tests.sh
```

Requires: `ucode`, `ucode-mod-fs`, `ucode-mod-uci`, `ucode-mod-ubus`,
`sed`, `sort`, `grep`, `awk` (standard OpenWrt buildroot tools).

Signed-off-by: Stan Grishin <redacted>
36 files changed:
net/adblock-fast/LICENSE [new file with mode: 0644]
net/adblock-fast/Makefile
net/adblock-fast/files/etc/init.d/adblock-fast [changed mode: 0755->0644]
net/adblock-fast/files/etc/uci-defaults/90-adblock-fast
net/adblock-fast/files/lib/adblock-fast/adblock-fast.uc [new file with mode: 0644]
net/adblock-fast/files/lib/adblock-fast/cli.uc [new file with mode: 0644]
net/adblock-fast/tests/01_pipeline/01_all_dns_modes [new file with mode: 0644]
net/adblock-fast/tests/01_pipeline/02_input_format_detection [new file with mode: 0644]
net/adblock-fast/tests/01_pipeline/03_subdomain_dedup [new file with mode: 0644]
net/adblock-fast/tests/01_pipeline/04_allowed_domains [new file with mode: 0644]
net/adblock-fast/tests/01_pipeline/05_canary_domains [new file with mode: 0644]
net/adblock-fast/tests/01_pipeline/06_servers_mode_allow [new file with mode: 0644]
net/adblock-fast/tests/01_pipeline/07_ipv6_addnhosts [new file with mode: 0644]
net/adblock-fast/tests/01_pipeline/08_ipv6_nftset [new file with mode: 0644]
net/adblock-fast/tests/01_pipeline/09_unbound_header [new file with mode: 0644]
net/adblock-fast/tests/02_config/01_blocked_domain_injection [new file with mode: 0644]
net/adblock-fast/tests/03_functional/01_check_domain [new file with mode: 0644]
net/adblock-fast/tests/03_functional/02_show_blocklist [new file with mode: 0644]
net/adblock-fast/tests/data/adblockplus.txt [new file with mode: 0644]
net/adblock-fast/tests/data/allowed.txt [new file with mode: 0644]
net/adblock-fast/tests/data/dnsmasq_servers.txt [new file with mode: 0644]
net/adblock-fast/tests/data/domains.txt [new file with mode: 0644]
net/adblock-fast/tests/data/hosts.txt [new file with mode: 0644]
net/adblock-fast/tests/lib/mocklib.uc [new file with mode: 0644]
net/adblock-fast/tests/lib/mocklib/ubus.uc [new file with mode: 0644]
net/adblock-fast/tests/lib/mocklib/uci.uc [new file with mode: 0644]
net/adblock-fast/tests/mocks/ubus/network.interface.wan~status.json [new file with mode: 0644]
net/adblock-fast/tests/mocks/ubus/network.interface~dump.json [new file with mode: 0644]
net/adblock-fast/tests/mocks/ubus/service~list~name-dnsmasq.json [new file with mode: 0644]
net/adblock-fast/tests/mocks/ubus/system~info.json [new file with mode: 0644]
net/adblock-fast/tests/mocks/uci/adblock-fast.json [new file with mode: 0644]
net/adblock-fast/tests/mocks/uci/dhcp.json [new file with mode: 0644]
net/adblock-fast/tests/mocks/uci/network.json [new file with mode: 0644]
net/adblock-fast/tests/mocks/uci/smartdns.json [new file with mode: 0644]
net/adblock-fast/tests/mocks/uci/unbound.json [new file with mode: 0644]
net/adblock-fast/tests/run_tests.sh [new file with mode: 0644]

diff --git a/net/adblock-fast/LICENSE b/net/adblock-fast/LICENSE
new file mode 100644 (file)
index 0000000..0ad25db
--- /dev/null
@@ -0,0 +1,661 @@
+                    GNU AFFERO GENERAL PUBLIC LICENSE
+                       Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+                            Preamble
+
+  The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+  The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works.  By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+  When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+  Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+  A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate.  Many developers of free software are heartened and
+encouraged by the resulting cooperation.  However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+  The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community.  It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server.  Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+  An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals.  This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+                       TERMS AND CONDITIONS
+
+  0. Definitions.
+
+  "This License" refers to version 3 of the GNU Affero General Public License.
+
+  "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+  "The Program" refers to any copyrightable work licensed under this
+License.  Each licensee is addressed as "you".  "Licensees" and
+"recipients" may be individuals or organizations.
+
+  To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy.  The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+  A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+  To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy.  Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+  To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies.  Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+  An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License.  If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+  1. Source Code.
+
+  The "source code" for a work means the preferred form of the work
+for making modifications to it.  "Object code" means any non-source
+form of a work.
+
+  A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+  The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form.  A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+  The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities.  However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work.  For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+  The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+  The Corresponding Source for a work in source code form is that
+same work.
+
+  2. Basic Permissions.
+
+  All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met.  This License explicitly affirms your unlimited
+permission to run the unmodified Program.  The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work.  This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+  You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force.  You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright.  Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+  Conveying under any other circumstances is permitted solely under
+the conditions stated below.  Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+  3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+  No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+  When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+  4. Conveying Verbatim Copies.
+
+  You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+  You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+  5. Conveying Modified Source Versions.
+
+  You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+    a) The work must carry prominent notices stating that you modified
+    it, and giving a relevant date.
+
+    b) The work must carry prominent notices stating that it is
+    released under this License and any conditions added under section
+    7.  This requirement modifies the requirement in section 4 to
+    "keep intact all notices".
+
+    c) You must license the entire work, as a whole, under this
+    License to anyone who comes into possession of a copy.  This
+    License will therefore apply, along with any applicable section 7
+    additional terms, to the whole of the work, and all its parts,
+    regardless of how they are packaged.  This License gives no
+    permission to license the work in any other way, but it does not
+    invalidate such permission if you have separately received it.
+
+    d) If the work has interactive user interfaces, each must display
+    Appropriate Legal Notices; however, if the Program has interactive
+    interfaces that do not display Appropriate Legal Notices, your
+    work need not make them do so.
+
+  A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit.  Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+  6. Conveying Non-Source Forms.
+
+  You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+    a) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by the
+    Corresponding Source fixed on a durable physical medium
+    customarily used for software interchange.
+
+    b) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by a
+    written offer, valid for at least three years and valid for as
+    long as you offer spare parts or customer support for that product
+    model, to give anyone who possesses the object code either (1) a
+    copy of the Corresponding Source for all the software in the
+    product that is covered by this License, on a durable physical
+    medium customarily used for software interchange, for a price no
+    more than your reasonable cost of physically performing this
+    conveying of source, or (2) access to copy the
+    Corresponding Source from a network server at no charge.
+
+    c) Convey individual copies of the object code with a copy of the
+    written offer to provide the Corresponding Source.  This
+    alternative is allowed only occasionally and noncommercially, and
+    only if you received the object code with such an offer, in accord
+    with subsection 6b.
+
+    d) Convey the object code by offering access from a designated
+    place (gratis or for a charge), and offer equivalent access to the
+    Corresponding Source in the same way through the same place at no
+    further charge.  You need not require recipients to copy the
+    Corresponding Source along with the object code.  If the place to
+    copy the object code is a network server, the Corresponding Source
+    may be on a different server (operated by you or a third party)
+    that supports equivalent copying facilities, provided you maintain
+    clear directions next to the object code saying where to find the
+    Corresponding Source.  Regardless of what server hosts the
+    Corresponding Source, you remain obligated to ensure that it is
+    available for as long as needed to satisfy these requirements.
+
+    e) Convey the object code using peer-to-peer transmission, provided
+    you inform other peers where the object code and Corresponding
+    Source of the work are being offered to the general public at no
+    charge under subsection 6d.
+
+  A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+  A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling.  In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage.  For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product.  A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+  "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source.  The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+  If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information.  But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+  The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed.  Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+  Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+  7. Additional Terms.
+
+  "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law.  If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+  When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it.  (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.)  You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+  Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+    a) Disclaiming warranty or limiting liability differently from the
+    terms of sections 15 and 16 of this License; or
+
+    b) Requiring preservation of specified reasonable legal notices or
+    author attributions in that material or in the Appropriate Legal
+    Notices displayed by works containing it; or
+
+    c) Prohibiting misrepresentation of the origin of that material, or
+    requiring that modified versions of such material be marked in
+    reasonable ways as different from the original version; or
+
+    d) Limiting the use for publicity purposes of names of licensors or
+    authors of the material; or
+
+    e) Declining to grant rights under trademark law for use of some
+    trade names, trademarks, or service marks; or
+
+    f) Requiring indemnification of licensors and authors of that
+    material by anyone who conveys the material (or modified versions of
+    it) with contractual assumptions of liability to the recipient, for
+    any liability that these contractual assumptions directly impose on
+    those licensors and authors.
+
+  All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10.  If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term.  If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+  If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+  Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+  8. Termination.
+
+  You may not propagate or modify a covered work except as expressly
+provided under this License.  Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+  However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+  Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+  Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License.  If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+  9. Acceptance Not Required for Having Copies.
+
+  You are not required to accept this License in order to receive or
+run a copy of the Program.  Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance.  However,
+nothing other than this License grants you permission to propagate or
+modify any covered work.  These actions infringe copyright if you do
+not accept this License.  Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+  10. Automatic Licensing of Downstream Recipients.
+
+  Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License.  You are not responsible
+for enforcing compliance by third parties with this License.
+
+  An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations.  If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+  You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License.  For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+  11. Patents.
+
+  A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based.  The
+work thus licensed is called the contributor's "contributor version".
+
+  A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version.  For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+  Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+  In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement).  To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+  If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients.  "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+  If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+  A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License.  You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+  Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+  12. No Surrender of Others' Freedom.
+
+  If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all.  For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+  13. Remote Network Interaction; Use with the GNU General Public License.
+
+  Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software.  This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+  Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work.  The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+  14. Revised Versions of this License.
+
+  The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time.  Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+  Each version is given a distinguishing version number.  If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation.  If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+  If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+  Later license versions may give you additional or different
+permissions.  However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+  15. Disclaimer of Warranty.
+
+  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+  16. Limitation of Liability.
+
+  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+  17. Interpretation of Sections 15 and 16.
+
+  If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+                     END OF TERMS AND CONDITIONS
+
+            How to Apply These Terms to Your New Programs
+
+  If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+  To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published
+    by the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+  If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source.  For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code.  There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+  You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+<https://www.gnu.org/licenses/>.
index e1bd93dc978a4c9442669be910180183fa97b9dd..a8a1acb69d7ac6eaca32b46477e000356bcabc26 100644 (file)
@@ -4,8 +4,8 @@
 include $(TOPDIR)/rules.mk
 
 PKG_NAME:=adblock-fast
-PKG_VERSION:=1.2.1
-PKG_RELEASE:=7
+PKG_VERSION:=1.2.2
+PKG_RELEASE:=6
 PKG_MAINTAINER:=Stan Grishin <stangri@melmac.ca>
 PKG_LICENSE:=AGPL-3.0-or-later
 
@@ -15,12 +15,15 @@ define Package/adblock-fast
   SECTION:=net
   CATEGORY:=Network
   TITLE:=AdBlock Fast Service
-  URL:=https://github.com/stangri/adblock-fast/
+  URL:=https://github.com/mossdef-org/adblock-fast/
   PKGARCH:=all
   DEPENDS:= \
-       +jshn \
        +curl \
        +resolveip \
+       +ucode \
+       +ucode-mod-fs \
+       +ucode-mod-uci \
+       +ucode-mod-ubus \
        +!BUSYBOX_DEFAULT_AWK:gawk \
        +!BUSYBOX_DEFAULT_GREP:grep \
        +!BUSYBOX_DEFAULT_SED:sed \
@@ -46,31 +49,21 @@ endef
 define Package/adblock-fast/install
        $(INSTALL_DIR) $(1)/etc/init.d
        $(INSTALL_BIN) ./files/etc/init.d/adblock-fast $(1)/etc/init.d/adblock-fast
-       $(SED) "s|^\(readonly PKG_VERSION\).*|\1='$(PKG_VERSION)-r$(PKG_RELEASE)'|" $(1)/etc/init.d/adblock-fast
+       $(INSTALL_DIR) $(1)/lib/adblock-fast
+       $(INSTALL_DATA) ./files/lib/adblock-fast/adblock-fast.uc $(1)/lib/adblock-fast/adblock-fast.uc
+       $(INSTALL_DATA) ./files/lib/adblock-fast/cli.uc $(1)/lib/adblock-fast/cli.uc
+       $(SED) "s|^\(\tversion:\).*|\1 '$(PKG_VERSION)-r$(PKG_RELEASE)',|" $(1)/lib/adblock-fast/adblock-fast.uc
        $(INSTALL_DIR) $(1)/etc/config
        $(INSTALL_CONF) ./files/etc/config/adblock-fast $(1)/etc/config/adblock-fast
        $(INSTALL_DIR) $(1)/etc/uci-defaults
        $(INSTALL_BIN) ./files/etc/uci-defaults/90-adblock-fast $(1)/etc/uci-defaults/90-adblock-fast
 endef
 
-define Package/adblock-fast/postinst
-#!/bin/sh
-# check if we are on real system
-if [ -z "$${IPKG_INSTROOT}" ]; then
-       /etc/init.d/adblock-fast enable
-fi
-exit 0
-endef
-
 define Package/adblock-fast/prerm
 #!/bin/sh
-# check if we are on real system
 if [ -z "$${IPKG_INSTROOT}" ]; then
-       echo -n "Stopping adblock-fast service... "
-       { /etc/init.d/adblock-fast stop && \
-         /etc/init.d/adblock-fast killcache; } >/dev/null 2>&1 && echo "OK" || echo "FAIL"
-       echo -n "Removing rc.d symlink for adblock-fast... "
-       /etc/init.d/adblock-fast disable >/dev/null 2>&1 && echo "OK" || echo "FAIL"
+       echo -n "Removing adblock-fast cache... "
+       /etc/init.d/adblock-fast killcache >/dev/null 2>&1 && echo "OK" || echo "FAIL"
 fi
 exit 0
 endef
old mode 100755 (executable)
new mode 100644 (file)
index 73d5315..5971da1
@@ -1,10 +1,9 @@
 #!/bin/sh /etc/rc.common
-# Copyright 2023-2025 MOSSDeF, Stan Grishin (stangri@melmac.ca)
-# shellcheck disable=SC2015,SC3023,SC3043
+# Copyright 2023-2026 MOSSDeF, Stan Grishin (stangri@melmac.ca)
+# shellcheck disable=SC2015,SC3043
 
 # shellcheck disable=SC2034
 START=20
-# shellcheck disable=SC2034
 USE_PROCD=1
 LC_ALL=C
 
@@ -23,2678 +22,65 @@ if type extra_command 1>/dev/null 2>&1; then
 fi
 
 readonly packageName='adblock-fast'
-readonly PKG_VERSION='dev-test'
-readonly packageCompat='11'
-readonly serviceName="$packageName $PKG_VERSION"
-readonly packageMemoryThreshold='33554432'
-readonly packageConfigFile="/etc/config/${packageName}"
-readonly dnsmasqUnifiedFile="/var/run/${packageName}/${packageName}.dnsmasq"
-readonly dnsmasqAddnhostsFile="/var/run/${packageName}/dnsmasq.addnhosts"
-readonly dnsmasqAddnhostsCache="/var/run/${packageName}/dnsmasq.addnhosts.cache"
-readonly dnsmasqAddnhostsGzip="${packageName}.dnsmasq.addnhosts.gz"
-readonly dnsmasqAddnhostsOutputFormatFilter='s|^|127.0.0.1 |;s|$||'
-readonly dnsmasqAddnhostsOutputFormatFilterIPv6='s|^|:: |;s|$||'
-readonly dnsmasqAddnhostsOutputParseFilter='s|^127.0.0.1 ||;s|^:: ||;'
-readonly dnsmasqAddnhostsGrepPatternIPv4='s|^|^127\.0\.0\.1 |'
-readonly dnsmasqAddnhostsGrepPatternIPv6='s|^|^:: |'
-readonly dnsmasqConfFile="$dnsmasqUnifiedFile"
-readonly dnsmasqConfCache="/var/run/${packageName}/dnsmasq.conf.cache"
-readonly dnsmasqConfGzip="${packageName}.dnsmasq.conf.gz"
-readonly dnsmasqConfOutputFormatFilter='s|^|local=/|;s|$|/|'
-readonly dnsmasqConfOutputParseFilter='s|local=/||;s|/$||;'
-readonly dnsmasqConfGrepPattern='s|^|^local=/|;s|$|/$|'
-readonly dnsmasqIpsetFile="$dnsmasqUnifiedFile"
-readonly dnsmasqIpsetCache="/var/run/${packageName}/dnsmasq.ipset.cache"
-readonly dnsmasqIpsetGzip="${packageName}.dnsmasq.ipset.gz"
-readonly dnsmasqIpsetOutputFormatFilter='s|^|ipset=/|;s|$|/adb|'
-readonly dnsmasqIpsetOutputParseFilter='s|ipset=/||;s|/adb$||;'
-readonly dnsmasqIpsetGrepPattern='s|^|^ipset=/|;s|$|/adb$|'
-readonly dnsmasqNftsetFile="$dnsmasqUnifiedFile"
-readonly dnsmasqNftsetCache="/var/run/${packageName}/dnsmasq.nftset.cache"
-readonly dnsmasqNftsetGzip="${packageName}.dnsmasq.nftset.gz"
-readonly dnsmasqNftsetOutputFormatFilter='s|^|nftset=/|;s|$|/4#inet#fw4#adb4|'
-readonly dnsmasqNftsetOutputFormatFilterIPv6='s|^|nftset=/|;s|$|/4#inet#fw4#adb4,6#inet#fw4#adb6|'
-readonly dnsmasqNftsetOutputParseFilter='s|nftset=/||;s|/4#.*$||;'
-readonly dnsmasqNftsetGrepPattern='s|^|^nftset=/|;s|$|/4#.*$|'
-readonly dnsmasqServersFile="/var/run/${packageName}/dnsmasq.servers"
-readonly dnsmasqServersCache="/var/run/${packageName}/dnsmasq.servers.cache"
-readonly dnsmasqServersGzip="${packageName}.dnsmasq.servers.gz"
-readonly dnsmasqServersOutputFormatFilter='s|^|server=/|;s|$|/|'
-readonly dnsmasqServersAllowFilter='s|(.*)|server=/\1/#|'
-readonly dnsmasqServersBlockedCountFilter='\|/#|d'
-readonly dnsmasqServersOutputParseFilter='s|server=/||;s|/.*$||;'
-readonly dnsmasqServersGrepPattern='s|^|^server=/|;s|$|/$|'
-readonly smartdnsDomainSetFile="/var/run/${packageName}/smartdns.domainset"
-readonly smartdnsDomainSetCache="/var/run/${packageName}/smartdns.domainset.cache"
-readonly smartdnsDomainSetConfig="/var/run/${packageName}/smartdns.domainset.conf"
-readonly smartdnsDomainSetGzip="${packageName}.smartdns.domainset.gz"
-readonly smartdnsDomainSetOutputFormatFilter=''
-readonly smartdnsDomainSetOutputParseFilter=''
-readonly smartdnsIpsetFile="/var/run/${packageName}/smartdns.ipset"
-readonly smartdnsIpsetCache="/var/run/${packageName}/smartdns.ipset.cache"
-readonly smartdnsIpsetConfig="/var/run/${packageName}/smartdns.ipset.conf"
-readonly smartdnsIpsetGzip="${packageName}.smartdns.ipset.gz"
-readonly smartdnsIpsetOutputFormatFilter=''
-readonly smartdnsIpsetOutputParseFilter=''
-readonly smartdnsNftsetFile="/var/run/${packageName}/smartdns.nftset"
-readonly smartdnsNftsetCache="/var/run/${packageName}/smartdns.nftset.cache"
-readonly smartdnsNftsetConfig="/var/run/${packageName}/smartdns.nftset.conf"
-readonly smartdnsNftsetGzip="${packageName}.smartdns.nftset.gz"
-readonly smartdnsNftsetOutputFormatFilter=''
-readonly smartdnsNftsetOutputParseFilter=''
-readonly unboundFile="/var/lib/unbound/adb_list.${packageName}"
-readonly unboundCache="/var/run/${packageName}/unbound.cache"
-readonly unboundGzip="${packageName}.unbound.gz"
-readonly unboundOutputFormatFilter='s|^|local-zone: "|;s|$|." always_nxdomain|'
-readonly unboundOutputParseFilter='s|^local-zone: "||;s|." always_nxdomain$||;'
-
-readonly ALLOWED_TMP="/var/${packageName}.allowed.tmp"
-readonly A_TMP="/var/${packageName}.a.tmp"
-readonly B_TMP="/var/${packageName}.b.tmp"
-readonly SED_TMP="/var/${packageName}.sed.tmp"
-readonly uciConfigFile="/etc/config/${packageName}"
-readonly runningConfigFile="/dev/shm/${packageName}"
-readonly runningStatusFile="/dev/shm/${packageName}.status.json"
-readonly runningStatusFileLock="/var/lock/${packageName}.lock"
-readonly hostsFilter='/localhost/d;/^#/d;/^[^0-9]/d;s/^0\.0\.0\.0.//;s/^127\.0\.0\.1.//;s/[[:space:]]*#.*$//;s/[[:cntrl:]]$//;s/[[:space:]]//g;/[`~!@#\$%\^&\*()=+;:"'\'',<>?/\|[{}]/d;/]/d;/\./!d;/^$/d;/[^[:alnum:]_.-]/d;'
-# Validating domains filter
-readonly domainsFilter='/^#/d;s/[[:space:]]*#.*|[[:space:]]*$|[[:cntrl:]]$//g;/^[[:space:]]*$/d;/^-|^\.|\.\.|-$|\.$|^[0-9.]+$|^[^[:alnum:]]|[`~!@#\$%\^&\*()=+;:"'"'"',<>?/\|{}]/d;/\./!d'
-# Lax domains filter
-#readonly domainsFilter='/^#/d;s/[[:space:]]*#.*|[[:space:]]*$|[[:cntrl:]]$//g;/^[[:space:]]*$/d;/^[^[:alnum:]._-]|[`~!@#\$%\^&\*()=+;:"'"'"',<>?/\|{}]/d;/\./!d'
-readonly adBlockPlusFilter='/^#/d;/^!/d;s/[[:space:]]*#.*$//;s/^||//;s/\^$//;s/[[:space:]]*$//;s/[[:cntrl:]]$//;/[[:space:]]/d;/[`~!@#\$%\^&\*()=+;:"'\'',<>?/\|[{}]/d;/]/d;/\./!d;/^$/d;/[^[:alnum:]_.-]/d;'
-readonly dnsmasqFileFilter='\|^server=/[[:alnum:]_.-].*/|!d;s|server=/||;s|/.*$||'
-readonly dnsmasq2FileFilter='\|^local=/[[:alnum:]_.-].*/|!d;s|local=/||;s|/.*$||'
-readonly dnsmasq3FileFilter='\|^address=/[[:alnum:]_.-].*/|!d;s|address=/||;s|/.*$||'
-readonly _DOT_='.'
-readonly __DOT__='[w]'
-readonly _OK_='\033[0;32m\xe2\x9c\x93\033[0m'
-readonly __OK__='\033[0;32m[\xe2\x9c\x93]\033[0m'
-readonly _FAIL_='\033[0;31m\xe2\x9c\x97\033[0m'
-readonly __FAIL__='\033[0;31m[\xe2\x9c\x97]\033[0m'
-readonly _WARN_='\033[0;33m\xe2\x9c\x94\033[0m'
-readonly __WARN__='\033[0;33m[\xe2\x9c\x94]\033[0m'
-readonly _ERROR_='\033[0;31m[ERROR]\033[0m'
-readonly _WARNING_='\033[0;33m[WARN]\033[0m'
-# shellcheck disable=SC2155
-readonly ipset="$(command -v ipset)"
-# shellcheck disable=SC2155
-readonly nft="$(command -v nft)"
-readonly canaryDomainsMozilla='use-application-dns.net'
-readonly canaryDomainsiCloud='mask.icloud.com mask-h2.icloud.com'
-readonly triggersReload='parallel_downloads debug download_timeout allowed_domain blocked_domain allowed_url blocked_url dns config_update_enabled config_update_url dnsmasq_config_file_url curl_additional_param curl_max_file_size curl_retry'
-readonly triggersRestart='compressed_cache compressed_cache_dir force_dns led force_dns_port'
-
-# Silence "Command failed: Not found" for redundant procd service delete calls
-__UBUS_BIN="$(command -v ubus || echo /bin/ubus)"
-ubus() {
-       if [ "$1" = "call" ] && [ "$2" = "service" ] && [ "$3" = "delete" ]; then
-               "$__UBUS_BIN" "$@" >/dev/null 2>&1 || true
-       else
-               "$__UBUS_BIN" "$@"
-       fi
-}
-
-dl_command=
-dl_flag=
-isSSLSupported=
-loadEnvironmentFlag=
-loadPackageConfigFlag=
-outputAllowFilter=
-outputBlockedCountFilter=
-outputFilter=
-outputFilterIPv6=
-outputFile=
-outputGzip=
-outputCache=
-stripToDomainsFilter=
-triggerStatus=
-awk='awk'
-allowed_url=
-blocked_url=
-fw4_restart_flag=
-adbf_boot_flag=
-dnsmasq_features=
-dnsmasq_ubus=
-
-# package config variables
-allow_non_ascii=
-canary_domains_icloud=
-canary_domains_mozilla=
-compressed_cache=
-config_update_enabled=
-debug_init_script=
-debug_performance=
-dnsmasq_sanity_check=
-dnsmasq_validity_check=
-enabled=
-force_dns=
-ipv6_enabled=
-parallel_downloads=
-procd_trigger_wan6=
-sanity_check=
-update_config_sizes=
-allowed_domain=
-blocked_domain=
-compressed_cache_dir=
-config_update_url=
-curl_additional_param=
-curl_max_file_size=
-curl_retry=
-dns=
-dnsmasq_config_file_url=
-dnsmasq_instance=
-download_timeout=
-force_dns_interface=
-force_dns_port=
-heartbeat_domain=
-heartbeat_sleep_timeout=
-led=
-pause_timeout=
-procd_boot_wan_timeout=
-smartdns_instance=
-verbosity=
+readonly _ucode="ucode -S -L /lib/${packageName} /lib/${packageName}/cli.uc --"
+_procd_svc_data=
+_fw4_restart=
 
 # shellcheck disable=SC1091
 . "${IPKG_INSTROOT}/lib/functions.sh"
 # shellcheck disable=SC1091
 . "${IPKG_INSTROOT}/lib/functions/network.sh"
-# shellcheck disable=SC1091
-. "${IPKG_INSTROOT}/usr/share/libubox/jshn.sh"
-
-append_newline() { is_newline_ending "$1" || echo '' >> "$1"; }
-check_ipset() { { command -v ipset && /usr/sbin/ipset help hash:net; } >/dev/null 2>&1; }
-check_nft() { command -v nft >/dev/null 2>&1; }
-check_dnsmasq() { command -v dnsmasq >/dev/null 2>&1; }
-check_dnsmasq_feature () {
-       [ -z "$dnsmasq_features" ] && dnsmasq_features="$(dnsmasq --version | grep -m1 'Compile time options:' | cut -d: -f2) "
-       case "$1" in
-               idn) [ "${dnsmasq_features#* IDN }" != "$dnsmasq_features" ];;
-               ipset) [ "${dnsmasq_features#* ipset }" != "$dnsmasq_features" ];;
-               nftset) [ "${dnsmasq_features#* nftset }" != "$dnsmasq_features" ];;
-       esac
-}
-check_dnsmasq_ipset() { check_ipset && check_dnsmasq_feature 'ipset'; }
-check_dnsmasq_nftset() { check_nft && check_dnsmasq_feature 'nftset'; }
-check_smartdns() { command -v smartdns >/dev/null 2>&1; }
-check_smartdns_ipset() { check_smartdns && check_ipset; }
-check_smartdns_nftset() { check_smartdns && check_nft; }
-check_unbound() { command -v unbound >/dev/null 2>&1; }
-append_url() {
-       local cfg="$1" allow_var="${2:-allowed_url}" block_var="${3:-blocked_url}"
-       local old_value
-       local en action url
-       config_get_bool en "$cfg" enabled '1'
-       config_get action "$cfg" action 'block'
-       config_get url "$cfg" url
-       if [ "$en" = '1' ]; then
-               if [ "$action" = 'allow' ]; then
-                       old_value=$(eval echo "\$$allow_var")
-                       old_value="${old_value:+$old_value }${url}"
-                       eval "$allow_var"="\$old_value"
-               else
-                       old_value=$(eval echo "\$$block_var")
-                       old_value="${old_value:+$old_value }${url}"
-                       eval "$block_var"="\$old_value"
-               fi
-       fi
-}
-adb_config_cache() {
-       local param="$1" var="$2"
-       local _reload="$triggersReload"
-       local _restart="$triggersRestart"
-       local i ret
-       case "$param" in
-               create|set)
-                       cp -f "$uciConfigFile" "$runningConfigFile"
-               ;;
-               get)
-                       case "$var" in
-                       trigger_fw4)
-                               if [ -s "$runningConfigFile" ]; then
-                                       local UCI_CONFIG_DIR="${runningConfigFile%/*}"
-                                       is_fw4_restart_needed && ret='true'
-                               fi
-                               printf "%b" "$ret"
-                               return
-                       ;;
-                       trigger_service)
-                               local old_allowed_url old_blocked_url
-                               if [ ! -s "$runningConfigFile" ]; then
-                                       ret='on_boot'
-                               elif ! cmp -s "$uciConfigFile" "$runningConfigFile"; then
-#                                      ret='restart'
-#                              else
-                                       local current_allowed_url current_blocked_url
-                                       config_load "$uciConfigFile"
-                                       config_foreach append_url 'file_url' current_allowed_url current_blocked_url
-                                       if [ -z "$allowed_url" ] || [ -z "$blocked_url" ]; then 
-                                               config_load "$runningConfigFile"
-                                               config_foreach append_url 'file_url' allowed_url blocked_url
-                                       fi
-                                       for i in $_reload; do
-                                               local val_current val_old UCI_CONFIG_DIR
-                                               case "$i" in
-                                                       allowed_url)
-                                                               val_current="$current_allowed_url"
-                                                               val_old="$allowed_url"
-                                                       ;;
-                                                       blocked_url)
-                                                               val_current="$current_blocked_url"
-                                                               val_old="$blocked_url"
-                                                       ;;
-                                                       *)
-                                                               UCI_CONFIG_DIR=
-                                                               val_current="$(uci_get "$packageName" 'config' "$i")"
-                                                               UCI_CONFIG_DIR="${runningConfigFile%/*}"
-                                                               val_old="$(uci_get "$packageName" 'config' "$i")"
-                                                       ;;
-                                               esac
-                                               if [ "$val_current" != "$val_old" ]; then
-                                                       ret='download'
-                                                       unset _restart
-                                                       break
-                                               fi
-                                       done
-                                       for i in $_restart; do
-                                               local val_current val_old UCI_CONFIG_DIR
-                                               UCI_CONFIG_DIR=
-                                               val_current="$(uci_get "$packageName" 'config' "$i")"
-                                               UCI_CONFIG_DIR="${runningConfigFile%/*}"
-                                               val_old="$(uci_get "$packageName" 'config' "$i")"
-                                               if [ "$val_current" != "$val_old" ]; then
-                                                       ret='restart'
-                                                       break
-                                               fi
-                                       done
-                               fi
-                               printf "%b" "$ret"
-                               return
-                       ;;
-                       *)
-                               local UCI_CONFIG_DIR="${runningConfigFile%/*}"
-                               ret="$(uci_get "$packageName" 'config' "$var")"
-                               printf "%b" "$ret"
-                               return
-                       ;;
-               esac
-       ;;
-       esac
-}
-count_blocked_domains() {
-       if [ -n "$outputBlockedCountFilter" ]; then
-               [ -f "$outputFile" ] && sed "$outputBlockedCountFilter" "$outputFile" | wc -l || echo '0'
-       else
-               [ -f "$outputFile" ] && wc -l < "$outputFile" || echo '0'
-       fi
-}
-debug() { local __i __j; for __i in "$@"; do eval "__j=\$$__i"; echo "${__i}: ${__j} "; done; }
-debug_log() { local __i __j; for __i in "$@"; do eval "__j=\$$__i"; logger -t "$packageName" "${__i}: ${__j} "; done; }
-dns_set_output_values() {
-       case "$1" in
-               dnsmasq.addnhosts)
-                       outputFilter="$dnsmasqAddnhostsOutputFormatFilter"
-                       outputFile="$dnsmasqAddnhostsFile"
-                       outputCache="$dnsmasqAddnhostsCache"
-                       outputGzip="${compressed_cache_dir}/${dnsmasqAddnhostsGzip}"
-                       outputParseFilter="$dnsmasqAddnhostsOutputParseFilter"
-                       if [ -n "$ipv6_enabled" ]; then
-                               outputFilterIPv6="$dnsmasqAddnhostsOutputFormatFilterIPv6"
-                       fi
-               ;;
-               dnsmasq.conf)
-                       outputFilter="$dnsmasqConfOutputFormatFilter"
-                       outputFile="$dnsmasqConfFile"
-                       outputCache="$dnsmasqConfCache"
-                       outputGzip="${compressed_cache_dir}/${dnsmasqConfGzip}"
-                       outputParseFilter="$dnsmasqConfOutputParseFilter"
-               ;;
-               dnsmasq.ipset)
-                       outputFilter="$dnsmasqIpsetOutputFormatFilter"
-                       outputFile="$dnsmasqIpsetFile"
-                       outputCache="$dnsmasqIpsetCache"
-                       outputGzip="${compressed_cache_dir}/${dnsmasqIpsetGzip}"
-                       outputParseFilter="$dnsmasqIpsetOutputParseFilter"
-               ;;
-               dnsmasq.nftset)
-                       if [ -n "$ipv6_enabled" ]; then
-                               outputFilter="$dnsmasqNftsetOutputFormatFilterIPv6"
-                       else
-                               outputFilter="$dnsmasqNftsetOutputFormatFilter"
-                       fi
-                       outputFile="$dnsmasqNftsetFile"
-                       outputCache="$dnsmasqNftsetCache"
-                       outputGzip="${compressed_cache_dir}/${dnsmasqNftsetGzip}"
-                       outputParseFilter="$dnsmasqNftsetOutputParseFilter"
-               ;;
-               dnsmasq.servers)
-                       outputFilter="$dnsmasqServersOutputFormatFilter"
-                       outputFile="$dnsmasqServersFile"
-                       outputCache="$dnsmasqServersCache"
-                       outputGzip="${compressed_cache_dir}/${dnsmasqServersGzip}"
-                       outputParseFilter="$dnsmasqServersOutputParseFilter"
-                       outputAllowFilter="$dnsmasqServersAllowFilter"
-                       outputBlockedCountFilter="$dnsmasqServersBlockedCountFilter"
-               ;;
-               smartdns.domainset)
-                       outputFilter="$smartdnsDomainSetOutputFormatFilter"
-                       outputFile="$smartdnsDomainSetFile"
-                       outputCache="$smartdnsDomainSetCache"
-                       outputGzip="${compressed_cache_dir}/${smartdnsDomainSetGzip}"
-                       outputConfig="$smartdnsDomainSetConfig"
-                       outputParseFilter="$smartdnsDomainSetOutputParseFilter"
-               ;;
-               smartdns.ipset)
-                       outputFilter="$smartdnsIpsetOutputFormatFilter"
-                       outputFile="$smartdnsIpsetFile"
-                       outputCache="$smartdnsIpsetCache"
-                       outputGzip="${compressed_cache_dir}/${smartdnsIpsetGzip}"
-                       outputConfig="$smartdnsIpsetConfig"
-                       outputParseFilter="$smartdnsIpsetOutputParseFilter"
-               ;;
-               smartdns.nftset)
-                       outputFilter="$smartdnsNftsetOutputFormatFilter"
-                       outputFile="$smartdnsNftsetFile"
-                       outputCache="$smartdnsNftsetCache"
-                       outputGzip="${compressed_cache_dir}/${smartdnsNftsetGzip}"
-                       outputConfig="$smartdnsNftsetConfig"
-                       outputParseFilter="$smartdnsNftsetOutputParseFilter"
-               ;;
-               unbound.adb_list)
-                       outputFilter="$unboundOutputFormatFilter"
-                       outputFile="$unboundFile"
-                       outputCache="$unboundCache"
-                       outputGzip="${compressed_cache_dir}/${unboundGzip}"
-                       outputParseFilter="$unboundOutputParseFilter"
-               ;;
-       esac
-       resolver 'on_load'
-}
-dnsmasq_hup() { killall -q -s HUP dnsmasq; }
-dnsmasq_kill() { killall -q -s KILL dnsmasq; }
-dnsmasq_restart() { /etc/init.d/dnsmasq restart >/dev/null 2>&1; }
-is_enabled() { uci_get "$1" 'config' 'enabled' '0'; }
-is_fw4_restart_needed() {
-       [ -n "$fw4_restart_flag" ] && return 0
-       local dns force_dns
-       dns="$(uci_get "$packageName" 'config' 'dns' 'dnsmasq.servers')"
-       force_dns="$(uci_get "$packageName" 'config' 'force_dns' '1')"
-       if [ "$force_dns" = '1' ]; then
-               return 0
-       elif [ "$dns" = 'dnsmasq.ipset' ]; then
-               return 0
-       elif [ "$dns" = 'dnsmasq.nftset' ]; then
-               return 0
-       elif [ "$dns" = 'smartdns.ipset' ]; then
-               return 0
-       elif [ "$dns" = 'smartdns.nftset' ]; then
-               return 0
-       else
-               return 1
-       fi
-}
-is_integer() { case "$1" in ''|*[!0-9]*) return 1;; esac; [ "$1" -ge 1 ] && [ "$1" -le 65535 ] || return 1; return 0; }
-is_greater() { test "$(printf '%s\n' "$@" | sort -V | head -n 1)" != "$1"; }
-is_greater_or_equal() { test "$(printf '%s\n' "$@" | sort -V | head -n 1)" = "$2"; }
-# shellcheck disable=SC3057
-is_https_url() { [ "${1:0:8}" = "https://" ]; }
-is_newline_ending() { [ "$(tail -c1 "$1" | wc -l)" -ne '0' ]; }
-is_port_listening() {
-       local hex
-       is_integer "$1" || return 1
-       hex="$(printf '%04X' "$1")"
-       # TCP: state 0A == LISTEN
-       if awk -v h="$hex" 'NR>1{split($2,a,":"); if (toupper(a[2])==h && $4=="0A") {found=1}} END{exit found?0:1}' /proc/net/tcp /proc/net/tcp6 2>/dev/null; then
-               return 0
-       fi
-       # UDP: presence indicates a bound socket
-       if awk -v h="$hex" 'NR>1{split($2,a,":"); if (toupper(a[2])==h) {found=1}} END{exit found?0:1}' /proc/net/udp /proc/net/udp6 2>/dev/null; then
-               return 0
-       fi
-       return 1
-}
-is_present() { command -v "$1" >/dev/null 2>&1; }
-is_running() {
-       local i j
-       i="$(json get status)"
-       j="$(ubus_get_data status)"
-       if [ "$i" = 'statusStopped' ] || [ -z "${i}${j}" ]; then
-               return 1
-       else
-               return 0
-       fi
-}
-ipset() { "$ipset" "$@" >/dev/null 2>&1; }
-get_mem_available() {
-       local ram swap
-       ram="$( ubus call system info | jsonfilter -e '@.memory.available' )"
-       swap="$( ubus call system info | jsonfilter -e '@.swap.free' )"
-       echo "$((ram + swap))";
-}
-get_mem_total() {
-       local ram swap
-       ram="$( ubus call system info | jsonfilter -e '@.memory.total' )"
-       swap="$( ubus call system info | jsonfilter -e '@.swap.total' )"
-       echo "$((ram + swap))";
-}
-led_on(){ if [ -n "${1}" ] && [ -e "${1}/trigger" ]; then echo 'default-on' > "${1}/trigger" 2>&1; fi; }
-led_off(){ if [ -n "${1}" ] &&  [ -e "${1}/trigger" ]; then echo 'none' > "${1}/trigger" 2>&1; fi; }
-logger() { /usr/bin/logger -t "$packageName" "$@"; }
-logger_debug() { [ -n "$debug_performance" ] && /usr/bin/logger -t "$packageName [$$]" "$@"; }
-nft() { "$nft" "$@" >/dev/null 2>&1; }
-output_dot() { output 1 "$_DOT_"; output 2 "$__DOT__"; }
-output_ok() { output 1 "$_OK_"; output 2 "$__OK__\n"; }
-output_okn() { output 1 "$_OK_\n"; output 2 "$__OK__\n"; }
-output_warn() { output 1 "$_WARN_"; output 2 "$__WARN__\n"; }
-output_warnn() { output 1 "$_WARN_\n"; output 2 "$__WARN__\n"; }
-output_fail() { output 1 "$_FAIL_"; output 2 "$__FAIL__\n"; }
-output_failn() { output 1 "$_FAIL_\n"; output 2 "$__FAIL__\n"; }
-output_dns() {
-       case "$dns" in
-               dnsmasq.*) output 2 "[DNSM] $*";;
-               smartdns.*) output 2 "[SMRT] $*";;
-               unbound.*) output 2 "[UNBD] $*";;
-       esac
-}
-output_error() { output "${_ERROR_} $*!\n"; }
-output_warning() { output "${_WARNING_} $*!\n"; }
-print_json_bool() { json_init; json_add_boolean "$1" "$2"; json_dump; json_cleanup; }
-print_json_int() { json_init; json_add_int "$1" "$2"; json_dump; json_cleanup; }
-print_json_string() { json_init; json_add_string "$1" "$2"; json_dump; json_cleanup; }
-sanitize_domain() { printf '%s' "$1" | sed -E 's#^[a-z]+://##; s#/.*$##; s/:.*$//'; }
-sanitize_dir() { [ -d "$(readlink -fn "$1")" ] && readlink -fn "$1"; }
-smartdns_restart() { /etc/init.d/smartdns restart >/dev/null 2>&1; }
-# shellcheck disable=SC3060
-str_contains() { [ "${1//$2}" != "$1" ]; }
-str_contains_word() { echo "$1" | grep -qw "$2"; }
-str_first_word() { echo "${1%% *}"; }
-# shellcheck disable=SC2018,SC2019
-str_to_lower() { echo "$1" | tr 'A-Z' 'a-z'; }
-# shellcheck disable=SC2018,SC2019
-str_to_upper() { echo "$1" | tr 'a-z' 'A-Z'; }
-# shellcheck disable=SC3060
-str_replace() { echo "${1//$2/$3}"; }
-ubus_get_data() { ubus call service list "{\"name\":\"$packageName\"}" | jsonfilter -e "@['${packageName}'].instances.main.data.${1}"; }
-ubus_get_ports() { ubus call service list "{\"name\":\"$packageName\"}" | jsonfilter -e "@['${packageName}'].instances.main.data.firewall.*.dest_port"; }
-uci_get_protocol() { uci_get 'network' "$1" 'proto'; }
-unbound_restart() { /etc/init.d/unbound restart >/dev/null 2>&1; }
-
-json() {
-       {
-               flock -x 209
-               local status message stats i
-               local action="$1" param="$2" value="$3"; shift 3; local info="$*";
-               local _current_namespace="$_JSON_PREFIX"
-               json_set_namespace "${packageName//-/_}_"
-               [ "$param" = 'error' ] && param='errors'
-               [ "$param" = 'warning' ] && param='warnings'
-               { json_load_file "$runningStatusFile" || json_init; } >/dev/null 2>&1
-               { json_select 'data' || { json_add_object 'data'; json_close_object; json_select 'data'; }; } >/dev/null 2>&1
-               case "${action}:${param}" in
-                       'get:errors'|'get:warnings')
-                               json_select "$param" >/dev/null 2>&1 || return
-                               if [ -z "$value" ]; then
-                                       json_get_keys i
-                               else
-                                       json_select "$value" >/dev/null 2>&1
-                                       case "${info:-code}" in
-                                               'code'|'info') json_get_var 'i' "$info" >/dev/null 2>&1;;
-                                       esac
-                               fi
-                               printf "%b" "$i"
-                               json_set_namespace "$_current_namespace"
-                               return
-                       ;;
-                       get:*)
-                               json_get_var 'i' "$param" >/dev/null 2>&1
-                               printf "%b" "$i"
-                               json_set_namespace "$_current_namespace"
-                               return
-                       ;;
-                       'add:errors'|'add:warnings')
-                               { json_select "$param" || json_add_array "$param"; } >/dev/null 2>&1
-                               json_add_object ""
-                               json_add_string 'code' "$value"
-                               json_add_string 'info' "$info"
-                               json_close_object
-                               json_select ..
-                       ;;
-                       add:*)
-                               json_add_string "$param" "$value"
-                       ;;
-                       'del:all')
-                               json_add_string status ''
-                               json_add_string message ''
-                               json_add_string stats ''
-                               json_add_array errors
-                               json_close_array
-                               json_add_array warnings
-                               json_close_array
-                       ;;
-                       'del:errors'|'del:warnings')
-                               json_add_array "$param"
-                               json_close_array
-                       ;;
-                       del:*)
-                               json_add_string "$param" ''
-                       ;;
-                       'set:status'|'set:message'|'set:stats')
-                               json_add_string "$param" "$value"
-                       ;;
-               esac
-               json_add_string 'version' "$PKG_VERSION"
-               json_add_string 'packageCompat' "$packageCompat"
-               json_select ..
-               mkdir -p "${runningStatusFile%/*}"
-               json_dump > "$runningStatusFile"
-               sync
-               json_set_namespace "$_current_namespace"
-       } 209>"$runningStatusFileLock"
-}
-
-get_local_filesize() {
-       local file="$1" size
-       [ -f "$file" ] || return 0
-       if is_present stat; then
-               size="$(stat -c%s "$file")"
-       elif is_present wc; then
-               size="$(wc -c < "$file")"
-       fi
-# shellcheck disable=SC3037
-       echo -en "$size"
-}
-
-get_url_filesize() {
-       local url="$1" size size_command timeout_sec=2
-       [ -n "$url" ] || return 0
-                               if is_present 'curl'; then
-                                                               # shellcheck disable=SC1017
-                                                               size_command='curl --silent --insecure --fail --head --request GET'
-                                                               size="$($size_command --connect-timeout $timeout_sec "$url" | awk -F": " '{IGNORECASE=1}/content-length/ {gsub(/\r/, ""); print $2}' )"
-                               fi
-
-                               # Check if size is empty and fallback to uclient-fetch if necessary
-                               if [ -z "$size" ] && is_present 'uclient-fetch' ; then
-                                                               # shellcheck disable=SC1017
-                                                               size_command='uclient-fetch --spider'
-                                                               size="$($size_command --timeout $timeout_sec "$url" -O /dev/null 2>&1 | sed -n '/^Download/ s/.*(\([0-9]*\) bytes).*/\1/p')"
-                               fi
-       # shellcheck disable=SC3037
-       echo -en "$size"
-}
-
-# shellcheck disable=SC3060
-output() {
-       [ -z "$verbosity" ] && verbosity="$(uci_get "$packageName" 'config' 'verbosity' '1')"
-       [ "$#" -ne '1' ] && {
-               case "$1" in [0-9]) [ $((verbosity & $1)) -gt 0 ] && shift || return 0;; esac }
-       local msg="$*" queue="/dev/shm/$packageName-output"
-       [ -t 1 ] && printf "%b" "$msg"
-       [ "$msg" != "${msg//\\n}" ] && {
-               [ -s "$queue" ] && msg="$(cat "$queue")${msg}" && rm -f "$queue"
-               msg="$(printf "%b" "$msg" | sed 's/\x1b\[[0-9;]*m//g')"
-               logger -t "$packageName [$$]" "$(printf "%b" "$msg")"
-       } || printf "%b" "$msg" >> "$queue"
-}
-
-uci_add_list_if_new() {
-       local PACKAGE="$1"
-       local CONFIG="$2"
-       local OPTION="$3"
-       local VALUE="$4"
-       local i
-       [ -n "$PACKAGE" ] && [ -n "$CONFIG" ] && [ -n "$OPTION" ] && [ -n "$VALUE" ] || return 1
-       for i in $(uci_get "$PACKAGE" "$CONFIG" "$OPTION"); do
-               [ "$i" = "$VALUE" ] && return 0
-       done
-       uci_add_list "$PACKAGE" "$CONFIG" "$OPTION" "$VALUE"
-}
-
-uci_changes() {
-       local PACKAGE="$1"
-       local CONFIG="$2"
-       local OPTION="$3"
-       [ -s "${UCI_CONFIG_DIR:-/etc/config/}${PACKAGE}" ] && \
-       [ -n "$(/sbin/uci ${UCI_CONFIG_DIR:+-c $UCI_CONFIG_DIR} changes "$PACKAGE${CONFIG:+.$CONFIG}${OPTION:+.$OPTION}")" ]
-}
-
-get_text() {
-       local r="$1"; shift;
-       case "$r" in
-               errorConfigValidationFail) printf "The %s config validation failed" "$packageName";;
-               errorServiceDisabled) printf "The %s is currently disabled" "$packageName";;
-               errorNoDnsmasqIpset) 
-                       printf "The dnsmasq ipset support is enabled in %s, but dnsmasq is either not installed or installed dnsmasq does not support ipset" "$packageName";;
-               errorNoIpset) 
-                       printf "The dnsmasq ipset support is enabled in %s, but ipset is either not installed or installed ipset does not support 'hash:net' type" "$packageName";;
-               errorNoDnsmasqNftset) 
-                       printf "The dnsmasq nft set support is enabled in %s, but dnsmasq is either not installed or installed dnsmasq does not support nft set" "$packageName";;
-               errorNoNft) printf "The dnsmasq nft sets support is enabled in %s, but nft is not installed" "$packageName";;
-               errorNoWanGateway) printf "The %s failed to discover WAN gateway" "$serviceName";;
-               errorOutputDirCreate) printf "Failed to create directory for %s file" "$@";;
-               errorOutputFileCreate) printf "Failed to create %s file" "$@";;
-               errorFailDNSReload) printf "Failed to restart/reload DNS resolver";;
-               errorSharedMemory) printf "Failed to access shared memory";;
-               errorSorting) printf "Failed to sort data file";;
-               errorOptimization) printf "Failed to optimize data file";;
-               errorAllowListProcessing) printf "Failed to process allow-list";;
-               errorDataFileFormatting) printf "Failed to format data file";;
-               errorCopyingDataFile) printf "Failed to copy data file to '%s'" "$@";;
-               errorMovingDataFile) printf "Failed to move data file to '%s'" "$@";;
-               errorCreatingCompressedCache) printf "Failed to create compressed cache";;
-               errorRemovingTempFiles) printf "Failed to remove temporary files";;
-               errorRestoreCompressedCache) printf "Failed to unpack compressed cache";;
-               errorRestoreCache) printf "Failed to move '%s' to '%s'" "$outputCache" "$outputFile";;
-               errorOhSnap) printf "Failed to create block-list or restart DNS resolver";;
-               errorStopping) printf "Failed to stop %s" "$serviceName";;
-               errorDNSReload) printf "Failed to reload/restart DNS resolver";;
-               errorDownloadingConfigUpdate) printf "Failed to download Config Update file";;
-               errorDownloadingList) printf "Failed to download %s" "$@";;
-               errorParsingConfigUpdate) printf "Failed to parse Config Update file";;
-               errorParsingList) printf "Failed to parse";;
-               errorNoSSLSupport) printf "No HTTPS/SSL support on device";;
-               errorCreatingDirectory) printf "Failed to create output/cache/gzip file directory";;
-               errorDetectingFileType) printf "Failed to detect format";;
-               errorNothingToDo) printf "No blocked list URLs nor blocked-domains enabled";;
-               errorTooLittleRam) printf "Free ram (%s) is not enough to process all enabled block-lists" "$@";;
-               errorCreatingBackupFile) printf "Failed to create backup file %s" "$@";;
-               errorDeletingDataFile) printf "Failed to delete data file %s" "$@";;
-               errorRestoringBackupFile) printf "Failed to restore backup file %s" "$@";;
-               errorNoOutputFile) printf "Failed to create final block-list %s" "$@";;
-               errorNoHeartbeat) printf "Heartbeat domain is not accessible after resolver restart";;
-
-               statusNoInstall) printf "The %s is not installed or not found" "$serviceName";;
-               statusStopped) printf "stopped";;
-               statusStarting) printf "starting";;
-               statusRestarting) printf "restarting";;
-               statusForceReloading) printf "force-reloading";;
-               statusDownloading) printf "downloading";;
-               statusProcessing) printf "processing";;
-               statusFail) printf "failed to start";;
-               statusSuccess) printf "success";;
-               statusTriggerBootWait) printf "waiting for trigger (on_boot)";;
-               statusTriggerStartWait) printf "waiting for trigger (on_start)";;
-
-               warningExternalDnsmasqConfig)
-                       printf "Use of external dnsmasq config file detected, please set 'dns' option to 'dnsmasq.conf'";;
-               warningMissingRecommendedPackages) printf "Some recommended packages are missing";;
-               warningInvalidCompressedCacheDir) printf "Invalid compressed cache directory '%s'" "$@";;
-               warningFreeRamCheckFail) printf "Can't detect free RAM";;
-               warningSanityCheckTLD) printf "Sanity check discovered TLDs in %s" "$@";;
-               warningSanityCheckLeadingDot) printf "Sanity check discovered leading dots in %s" "$@";;
-               warningInvalidDomainsRemoved) printf "Removed %s invalid domain entries from block-list (domains starting with -/./numbers or containing invalid patterns)" "$@";;
-
-               *) printf "Unknown error/warning '%s'" "$@";;
-       esac
-}
-
-load_network() {
-       local param="$1"
-       local i j wan_if wan_gw
-       local counter wan_if_timeout="$procd_boot_wan_timeout" wan_gw_timeout='5'
-       counter=0
-       while [ -z "$wan_if" ]; do
-               network_flush_cache
-               network_find_wan wan_if
-               if [ -n "$wan_if" ]; then
-                       output 1 "WAN interface found: '${wan_if}'.\n"
-                       output 2 "[BOOT] WAN interface found: '${wan_if}'.\n"
-                       break
-               fi
-               if [ "$counter" -gt "$wan_if_timeout" ]; then
-                       output 1 "WAN interface timeout, assuming 'wan'.\n"
-                       output 2 "[BOOT] WAN interface timeout, assuming 'wan'.\n"
-                       wan_if='wan'
-                       break
-               fi
-               counter=$((counter+1))
-               output 1 "Waiting to discover WAN Interface...\n"
-               output 2 "[BOOT] Waiting to discover WAN Interface...\n"
-               sleep 1
-       done
-
-       counter=0
-       if [ "$(uci_get_protocol "$wan_if")" = 'pppoe' ]; then
-               wan_gw_timeout=$((wan_gw_timeout+10))
-       fi
-       while [ "$counter" -le "$wan_gw_timeout" ]; do
-               network_flush_cache
-               network_get_gateway wan_gw "$wan_if"
-               if [ -n "$wan_gw" ]; then
-                       output 1 "WAN gateway found: '${wan_gw}.'\n"
-                       output 2 "[BOOT] WAN gateway found: '${wan_gw}.'\n"
-                       return 0
-               fi
-               counter=$((counter+1))
-               output 1 "Waiting to discover $wan_if gateway...\n"
-               output 2 "[BOOT] Waiting to discover $wan_if gateway...\n"
-               sleep 1
-       done
-       json add error 'errorNoWanGateway'
-       output_error "$(get_text 'errorNoWanGateway')"
-       return 1
-}
-
-detect_file_type() {
-       local file="$1"
-       if [ "$(head -1 "$file")" = '[Adblock Plus]' ] || \
-               grep -q '^||' "$file"; then
-               echo 'adblockplus'
-       elif grep -q '^server=' "$file"; then
-               echo 'dnsmasq'
-       elif grep -q '^local=' "$file"; then
-               echo 'dnsmasq2'
-       elif grep -q '^address=' "$file"; then
-               echo 'dnsmasq3'
-       elif grep -q -e '^0\.0\.0\.0\s' -e '^127\.0\.0\.1\s' "$file"; then
-               echo 'hosts'
-       elif [ -n "$(sed "$domainsFilter" "$file" | head -1)" ]; then
-               echo 'domains'
-       fi
-}
-
-load_package_config() {
-       config_load    "$packageName"
-       config_get_bool allow_non_ascii          'config' 'allow_non_ascii'         '0'
-       config_get_bool canary_domains_icloud    'config' 'canary_domains_icloud'   '0'
-       config_get_bool canary_domains_mozilla   'config' 'canary_domains_mozilla'  '0'
-       config_get_bool compressed_cache         'config' 'compressed_cache'        '0'
-       config_get_bool config_update_enabled    'config' 'config_update_enabled'   '0'
-       config_get_bool debug_init_script        'config' 'debug_init_script'       '0'
-       config_get_bool debug_performance        'config' 'debug_performance'       '0'
-       config_get_bool dnsmasq_sanity_check     'config' 'dnsmasq_sanity_check'    '1'
-       config_get_bool dnsmasq_validity_check   'config' 'dnsmasq_validity_check'  '0'
-       config_get_bool enabled                  'config' 'enabled'                 '0'
-       config_get_bool force_dns                'config' 'force_dns'               '1'
-       config_get_bool ipv6_enabled             'config' 'ipv6_enabled'            '0'
-       config_get_bool parallel_downloads       'config' 'parallel_downloads'      '1'
-       config_get_bool procd_trigger_wan6       'config' 'procd_trigger_wan6'      '0'
-       config_get_bool update_config_sizes      'config' 'update_config_sizes'     '1'
-       config_get      allowed_domain           'config' 'allowed_domain'
-       config_get      blocked_domain           'config' 'blocked_domain'
-       config_get      compressed_cache_dir     'config' 'compressed_cache_dir'    '/etc'
-       config_get      config_update_url        'config' 'config_update_url'       'https://cdn.jsdelivr.net/gh/openwrt/packages/net/adblock-fast/files/adblock-fast.config.update'
-       config_get      curl_additional_param    'config' 'curl_additional_param'
-       config_get      curl_max_file_size       'config' 'curl_max_file_size'
-       config_get      curl_retry               'config' 'curl_retry'              '3'
-       config_get      dns                      'config' 'dns'                     'dnsmasq.servers'
-       config_get      dnsmasq_config_file_url  'config' 'dnsmasq_config_file_url'
-       config_get      dnsmasq_instance         'config' 'dnsmasq_instance'        '*'
-       config_get      download_timeout         'config' 'download_timeout'        '20'
-       config_get      force_dns_interface      'config' 'force_dns_interface'     'lan'
-       config_get      force_dns_port           'config' 'force_dns_port'          '53 853'
-       config_get      heartbeat_domain         'config' 'heartbeat_domain'        'heartbeat.melmac.ca'
-       config_get      heartbeat_sleep_timeout  'config' 'heartbeat_sleep_timeout' '10'
-       config_get      led                      'config' 'led'                     
-       config_get      pause_timeout            'config' 'pause_timeout'           '20'
-       config_get      procd_boot_wan_timeout   'config' 'procd_boot_wan_timeout'  '60'
-       config_get      smartdns_instance        'config' 'smartdns_instance'       '*'
-       config_get      verbosity                'config' 'verbosity'               '2'
-
-       [ "$allow_non_ascii" = '1' ]         || unset allow_non_ascii
-       [ "$canary_domains_icloud" = '1' ]   || unset canary_domains_icloud
-       [ "$canary_domains_mozilla" = '1' ]  || unset canary_domains_mozilla
-       [ "$compressed_cache" = '1' ]        || unset compressed_cache
-       [ "$config_update_enabled" = '1' ]   || unset config_update_enabled
-       [ "$debug_init_script" = '1' ]       || unset debug_init_script
-       [ "$debug_performance" = '1' ]       || unset debug_performance
-       [ "$dnsmasq_sanity_check" = '1' ]    || unset dnsmasq_sanity_check
-       [ "$dnsmasq_validity_check" = '1' ]  || unset dnsmasq_validity_check
-       [ "$enabled" = '1' ]                 || unset enabled
-       [ "$force_dns" = '1' ]               || unset force_dns
-       [ "$ipv6_enabled" = '1' ]            || unset ipv6_enabled
-       [ "$parallel_downloads" = '1' ]      || unset parallel_downloads
-       [ "$procd_trigger_wan6" = '1' ]      || unset procd_trigger_wan6
-       [ "$update_config_sizes" = '1' ]     || unset update_config_sizes
-
-       dns_set_output_values "$dns"
-       [ "$heartbeat_domain" = '-' ] && unset heartbeat_domain || heartbeat_domain="$(sanitize_domain "$heartbeat_domain")"
-       if [ "$(sanitize_dir "$compressed_cache_dir")" = '/' ]; then
-               compressed_cache_dir=''
-       elif [ -n "$(sanitize_dir "$compressed_cache_dir")" ]; then
-               compressed_cache_dir="$(sanitize_dir "$compressed_cache_dir")"
-       else
-               compressed_cache_dir="/etc"
-       fi
-
-       unset loadEnvironmentFlag
-       loadPackageConfigFlag='true'
-}
-
-load_dl_command() {
-       # Prefer curl because it supports the file:// scheme.
-       if is_present 'curl'; then
-               dl_command='curl -f --silent --insecure'
-               dl_command="${dl_command}${curl_additional_param:+ $curl_additional_param}"
-               dl_command="${dl_command}${curl_max_file_size:+ --max-filesize $curl_max_file_size}"
-               dl_command="${dl_command}${curl_retry:+ --retry $curl_retry}"
-               dl_command="${dl_command}${download_timeout:+ --connect-timeout $download_timeout}"
-               dl_flag='-o'
-       elif is_present '/usr/libexec/wget-ssl'; then
-               dl_command='/usr/libexec/wget-ssl --no-check-certificate -q'
-               dl_command="${dl_command}${download_timeout:+ --timeout $download_timeout}"
-               dl_flag="-O"
-               size_command='/usr/libexec/wget-ssl --no-check-certificate -q -O /dev/null --server-response'
-               size_command="${size_command}${download_timeout:+ --timeout $download_timeout}"
-       elif is_present wget && wget --version 2>/dev/null | grep -q "+https"; then
-               dl_command="wget --no-check-certificate -q"
-               dl_command="${dl_command}${download_timeout:+ --timeout $download_timeout}"
-               dl_flag="-O"
-               size_command='wget --no-check-certificate -q -O /dev/null --server-response'
-               size_command="${size_command}${download_timeout:+ --timeout $download_timeout}"
-       else
-               dl_command="uclient-fetch --no-check-certificate -q"
-               dl_command="${dl_command}${download_timeout:+ --timeout $download_timeout}"
-               dl_flag="-O"
-       fi
-       if curl --version 2>/dev/null | grep -q "Protocols: .*https.*" \
-               || wget --version 2>/dev/null | grep -q "+ssl"; then
-               isSSLSupported='true'
-       else
-               unset isSSLSupported
-       fi
-}
-
-load_environment() {
-       local i j
-       local param="$1" validation_result="$2"
-
-       [ -z "$loadEnvironmentFlag" ] || return 0
-       [ -n "$loadPackageConfigFlag" ] || load_package_config
-
-       if [ -z "$enabled" ]; then
-               json add error 'errorServiceDisabled'
-               output_error "$(get_text 'errorServiceDisabled')"
-               output "Run the following commands before starting service again:\n"
-               output "uci set ${packageName}.config.enabled='1'; uci commit $packageName;\n"
-               return 1
-       fi
-
-       if [ -n "$validation_result" ] && [ "$validation_result" != '0' ]; then
-               output 1 "$_FAIL_\n"
-               json add error 'errorConfigValidationFail'
-               output_error "$(get_text 'errorConfigValidationFail')"
-               output "Please check if the '$packageConfigFile' contains correct values for config options.\n"
-               return 1
-       fi
-
-       if [ -n "$debug_init_script" ]; then
-               exec 1>>"/tmp/$packageName.log"
-               exec 2>&1
-               set -x
-       fi
-
-       # Check for resolver presence and error out on start
-       case "$dns" in
-               dnsmasq.*)
-                       if ! check_dnsmasq; then
-                               [ "$param" != 'quiet' ] && { json add error 'errorDNSReload'; output_error "Resolver 'dnsmasq' not found"; }
-                               return 1
-                       fi
-                       if check_dnsmasq_feature 'idn'; then
-                               allow_non_ascii=''
-                       fi
-               ;;
-               smartdns.*)
-                       if ! check_smartdns; then
-                               [ "$param" != 'quiet' ] && { json add error 'errorDNSReload'; output_error "Resolver 'smartdns' not found"; }
-                               return 1
-                       fi
-                       allow_non_ascii=''
-               ;;
-               unbound.*)
-                       if ! check_unbound; then
-                               [ "$param" != 'quiet' ] && { json add error 'errorDNSReload'; output_error "Resolver 'unbound' not found"; }
-                               return 1
-                       fi
-                       allow_non_ascii='true'
-               ;;
-       esac
-
-       case "$dns" in
-               dnsmasq.ipset)
-                       if ! check_dnsmasq_feature 'ipset'; then
-                               if [ "$param" != 'quiet' ]; then
-                                       json add error 'errorNoDnsmasqIpset'
-#                                      output_error "$(get_text 'errorNoDnsmasqIpset')"
-                               fi
-                               dns='dnsmasq.servers'
-                       fi
-                       if ! ipset help hash:net; then
-                               if [ "$param" != 'quiet' ]; then
-                                       json add error 'errorNoIpset'
-#                                      output_error "$(get_text 'errorNoIpset')"
-                               fi
-                               dns='dnsmasq.servers'
-                       fi
-               ;;
-               dnsmasq.nftset)
-                       if ! check_dnsmasq_feature 'nftset'; then
-                               if [ "$param" != 'quiet' ]; then
-                                       json add error 'errorNoDnsmasqNftset'
-#                                      output_error "$(get_text 'errorNoDnsmasqNftset')"
-                               fi
-                               dns='dnsmasq.servers'
-                       fi
-                       if [ -z "$nft" ]; then
-                               if [ "$param" != 'quiet' ]; then
-                                       json add error 'errorNoNft'
-#                                      output_error "$(get_text 'errorNoNft')"
-                               fi
-                               dns='dnsmasq.servers'
-                       fi
-               ;;
-               smartdns.ipset)
-                       if ! ipset help hash:net; then
-                               if [ "$param" != 'quiet' ]; then
-                                       json add error 'errorNoIpset'
-#                                      output_error "$(get_text 'errorNoIpset')"
-                               fi
-                               dns='smartdns.domainset'
-                       fi
-               ;;
-               smartdns.nftset)
-                       if [ -z "$nft" ]; then
-                               if [ "$param" != 'quiet' ]; then
-                                       json add error 'errorNoNft'
-#                                      output_error "$(get_text 'errorNoNft')"
-                               fi
-                               dns='smartdns.domainset'
-                       fi
-               ;;
-       esac
-
-       if [ -n "$dnsmasq_config_file_url" ]; then
-               unset update_config_sizes
-               case "$dns" in
-                       dnsmasq.conf) :;;
-                       *)
-                               dns='dnsmasq.conf'
-                               if [ "$param" != 'quiet' ]; then
-                                       json add warning 'warningExternalDnsmasqConfig'
-                               fi
-                       ;;
-               esac
-       fi
-
-       [ "$dns" = 'dnsmasq.addnhosts' ]  || rm -f "$dnsmasqAddnhostsFile" "$dnsmasqAddnhostsCache" "${compressed_cache_dir}/${dnsmasqAddnhostsGzip}"
-       [ "$dns" = 'dnsmasq.conf' ]       || rm -f "$dnsmasqConfCache" "${compressed_cache_dir}/${dnsmasqConfGzip}"
-       [ "$dns" = 'dnsmasq.ipset' ]      || rm -f "$dnsmasqIpsetCache" "${compressed_cache_dir}/${dnsmasqIpsetGzip}"
-       [ "$dns" = 'dnsmasq.nftset' ]     || rm -f "$dnsmasqNftsetCache" "${compressed_cache_dir}/${dnsmasqNftsetGzip}"
-       [ "$dns" = 'dnsmasq.servers' ]    || rm -f "$dnsmasqServersFile" "$dnsmasqServersCache" "${compressed_cache_dir}/${dnsmasqServersGzip}"
-       [ "$dns" = 'smartdns.domainset' ] || rm -f "$smartdnsDomainSetFile" "$smartdnsDomainSetCache" "${compressed_cache_dir}/${smartdnsDomainSetGzip}" "$smartdnsDomainSetConfig"
-       [ "$dns" = 'smartdns.ipset' ]     || rm -f "$smartdnsIpsetFile" "$smartdnsIpsetCache" "${compressed_cache_dir}/${smartdnsIpsetGzip}" "$smartdnsIpsetConfig"
-       [ "$dns" = 'smartdns.nftset' ]    || rm -f "$smartdnsNftsetFile" "$smartdnsNftsetCache" "${compressed_cache_dir}/${smartdnsNftsetGzip}" "$smartdnsNftsetConfig"
-       [ "$dns" = 'unbound.adb_list' ]   || rm -f "$unboundFile" "$unboundCache" "${compressed_cache_dir}/${unboundGzip}"
-
-       for i in "$runningConfigFile" "$runningStatusFile" "$outputFile" "$outputCache" "$outputGzip" "$outputConfig"; do
-               [ -n "$i" ] || continue
-               if ! mkdir -p "${i%/*}"; then
-                       if [ "$param" != 'quiet' ]; then
-                               json add error 'errorOutputDirCreate' "$i"
-                       fi
-               fi
-       done
-
-       is_present 'gawk' && awk='gawk'
-       if ! is_present '/usr/libexec/grep-gnu' || ! is_present '/usr/libexec/sed-gnu' || \
-               ! is_present '/usr/libexec/sort-coreutils' || ! is_present 'gawk'; then
-                       local s
-                       is_present 'gawk' || { json add warning 'warningMissingRecommendedPackages' 'gawk'; s="${s:+$s }gawk"; }
-                       is_present '/usr/libexec/grep-gnu' || { json add warning 'warningMissingRecommendedPackages' 'grep'; s="${s:+$s }grep"; }
-                       is_present '/usr/libexec/sed-gnu' || { json add warning 'warningMissingRecommendedPackages' 'sed'; s="${s:+$s }sed"; }
-                       is_present '/usr/libexec/sort-coreutils' || { json add warning 'warningMissingRecommendedPackages' 'coreutils-sort'; s="${s:+$s }coreutils-sort"; }
-                       if [ "$param" != 'quiet' ]; then
-                               output_warning "$(get_text 'warningMissingRecommendedPackages'), install them by running:"
-                               output "opkg update; opkg --force-overwrite install $s;"
-                       fi
-       fi
-
-       load_dl_command
-
-       led="${led:+/sys/class/leds/$led}"
-       config_load "$packageName"
-       config_foreach append_url 'file_url' allowed_url blocked_url
-       loadEnvironmentFlag='true'
-       adb_file 'test_cache' && return 0
-       adb_file 'test_gzip' && return 0
-       if [ "$param" = 'on_boot' ]; then
-               load_network "$param"
-               return "$?"
-       else
-               return 0
-       fi
-}
-
-resolver() {
-       _dnsmasq_instance_get_confdir() {
-               local cfg_file
-               [ -z "$dnsmasq_ubus" ] && dnsmasq_ubus="$(ubus call service list '{"name":"dnsmasq"}')"
-               cfg_file="$(echo "$dnsmasq_ubus" | jsonfilter -e "@.dnsmasq.instances.${1}.command" \
-                       | awk '{gsub(/\\\//,"/");gsub(/[][",]/,"");for(i=1;i<=NF;i++)if($i=="-C"){print $(i+1);exit}}')"
-               awk -F= '/^conf-dir=/{print $2; exit}' "$cfg_file"
-       }
-       _dnsmasq_instance_config() {
-               local cfg="$1" param="$2" confdir
-               [ -s "/etc/config/dhcp" ] || return 0
-               [ -n "$(uci_get dhcp "$cfg")" ] || return 1
-               case "$param" in
-                       dnsmasq.addnhosts)
-                               # clean up other dnsmasq configs
-                               confdir="$(_dnsmasq_instance_get_confdir "$cfg")"
-                               [ -n "$confdir" ] && rm -f "${confdir}/${packageName}"
-                               uci_remove_list 'dhcp' "$cfg" 'addnmount' "$dnsmasqConfFile"
-                               if [ "$(uci_get 'dhcp' "$cfg" 'serversfile')" = "$dnsmasqServersFile" ]; then
-                                       uci_remove 'dhcp' "$cfg" 'serversfile'
-                               fi
-                               # add dnsmasq addnhosts config
-                               uci_add_list_if_new 'dhcp' "$cfg" 'addnhosts' "$dnsmasqAddnhostsFile"
-                       ;;
-                       cleanup|unbound.adb_list)
-                               # clean up all dnsmasq configs
-                               confdir="$(_dnsmasq_instance_get_confdir "$cfg")"
-                               [ -n "$confdir" ] && rm -f "${confdir}/${packageName}"
-                               uci_remove_list 'dhcp' "$cfg" 'addnhosts' "$dnsmasqAddnhostsFile"
-                               uci_remove_list 'dhcp' "$cfg" 'addnmount' "$dnsmasqConfFile"
-                               if [ "$(uci_get 'dhcp' "$cfg" 'serversfile')" = "$dnsmasqServersFile" ]; then
-                                       uci_remove 'dhcp' "$cfg" 'serversfile'
-                               fi
-                       ;;
-                       dnsmasq.conf|dnsmasq.ipset|dnsmasq.nftset)
-                               # clean up other dnsmasq configs
-                               uci_remove_list 'dhcp' "$cfg" 'addnhosts' "$dnsmasqAddnhostsFile"
-                               if [ "$(uci_get 'dhcp' "$cfg" 'serversfile')" = "$dnsmasqServersFile" ]; then
-                                       uci_remove 'dhcp' "$cfg" 'serversfile'
-                               fi
-                               # add dnsmasq conf addnmount to point to adblock-fast file
-                               uci_add_list_if_new 'dhcp' "$cfg" 'addnmount' "$dnsmasqConfFile"
-                               # add softlink to adblock-fast file
-                               confdir="$(_dnsmasq_instance_get_confdir "$cfg")"
-                               [ -n "$confdir" ] || return 1
-                               ln -sf "$dnsmasqConfFile" "${confdir}/${packageName}"
-                               chmod 660 "${confdir}/${packageName}"
-                               chown -h root:dnsmasq "${confdir}/${packageName}" >/dev/null 2>/dev/null
-                       ;;
-                       dnsmasq.servers)
-                               # clean up other dnsmasq configs
-                               uci_remove_list 'dhcp' "$cfg" 'addnhosts' "$dnsmasqAddnhostsFile"
-                               confdir="$(_dnsmasq_instance_get_confdir "$cfg")"
-                               [ -n "$confdir" ] && rm -f "${confdir}/${packageName}"
-                               uci_remove_list 'dhcp' "$cfg" 'addnmount' "$dnsmasqConfFile"
-                               # add dnsmasq servers config
-                               if [ "$(uci_get 'dhcp' "$cfg" 'serversfile')" != "$dnsmasqServersFile" ]; then
-                                       uci_set 'dhcp' "$cfg" 'serversfile' "$dnsmasqServersFile"
-                               fi
-                       ;;
-               esac
-       }
-# shellcheck disable=SC2317
-       _dnsmasq_instance_append_force_dns_port() {
-               local cfg="$1" instance_port
-               [ -s "/etc/config/dhcp" ] || return 0
-               [ -n "$(uci_get 'dhcp' "$cfg")" ] || return 1
-               config_get instance_port "$cfg" 'port' '53'
-               str_contains_word "$force_dns_port" "$instance_port" || force_dns_port="${force_dns_port:+$force_dns_port }${instance_port}"
-       }
-       _smartdns_instance_append_force_dns_port() {
-               local cfg="$1" instance_port
-               [ -s "/etc/config/smartdns" ] || return 0
-               [ -n "$(uci_get 'smartdns' "$cfg")" ] || return 1
-               config_get instance_port "$cfg" 'port' '53'
-               str_contains_word "$force_dns_port" "$instance_port" || force_dns_port="${force_dns_port:+$force_dns_port }${instance_port}"
-       }
-       _smartdns_instance_config() {
-               local cfg="$1" param="$2"
-               [ -s "/etc/config/smartdns" ] || return 0
-               [ -n "$(uci_get 'smartdns' "$cfg")" ] || return 1
-               case "$param" in
-                       cleanup)
-                               uci_remove_list 'smartdns' "$cfg" 'conf_files' "$outputConfig"
-                               rm -f "$outputConfig"
-                       ;;
-                       smartdns.domainset)
-                               { echo "domain-set -name adblock-fast -file $outputFile"; \
-                               echo "domain-rules /domain-set:adblock-fast/ -a #"; } > "$outputConfig"
-                               uci_add_list_if_new 'smartdns' "$cfg" 'conf_files' "$outputConfig"
-                       ;;
-                       smartdns.ipset)
-                               { echo "domain-set -name adblock-fast -file $outputFile"; \
-                               echo "domain-rules /domain-set:adblock-fast/ -ipset adb"; } > "$outputConfig"
-                               uci_add_list_if_new 'smartdns' "$cfg" 'conf_files' "$outputConfig"
-                       ;;
-                       smartdns.nftset)
-                               local nftset="#4:inet#fw4#adb4"
-                               [ -n "$ipv6_enabled" ] && nftset="${nftset},#6:inet#fw4#adb6"
-                               { echo "domain-set -name adblock-fast -file $outputFile"; \
-                               echo "domain-rules /domain-set:adblock-fast/ -nftset $nftset"; } > "$outputConfig"
-                               uci_add_list_if_new 'smartdns' "$cfg" 'conf_files' "$outputConfig"
-                       ;;
-               esac
-       }
-# shellcheck disable=SC2317,SC2329
-       _unbound_instance_append_force_dns_port() {
-               [ -s "/etc/config/unbound" ] || return 0
-               [ -n "$(uci_get 'unbound' "$cfg")" ] || return 1
-               local cfg="$1" instance_port
-               config_get instance_port "$cfg" 'listen_port' '53'
-               str_contains_word "$force_dns_port" "$instance_port" || force_dns_port="${force_dns_port:+$force_dns_port }${instance_port}"
-       }
-       
-       local i resolver_name="${dns%%.*}"
-       [ -z "$1" ] && return 0
-       case $1 in
-               cleanup)
-                       rm -f "$dnsmasqAddnhostsFile" "$dnsmasqAddnhostsCache" "${compressed_cache_dir}/${dnsmasqAddnhostsGzip}"
-                       rm -f "$dnsmasqConfCache" "${compressed_cache_dir}/${dnsmasqConfGzip}"
-                       rm -f "$dnsmasqIpsetCache" "${compressed_cache_dir}/${dnsmasqIpsetGzip}"
-                       rm -f "$dnsmasqNftsetCache" "${compressed_cache_dir}/${dnsmasqNftsetGzip}"
-                       rm -f "$dnsmasqServersFile" "$dnsmasqServersCache" "${compressed_cache_dir}/${dnsmasqServersGzip}"
-                       rm -f "$smartdnsDomainSetFile" "$smartdnsDomainSetCache" "${compressed_cache_dir}/${smartdnsDomainSetGzip}" "$smartdnsDomainSetConfig"
-                       rm -f "$smartdnsIpsetFile" "$smartdnsIpsetCache" "${compressed_cache_dir}/${smartdnsIpsetGzip}" "$smartdnsIpsetConfig"
-                       rm -f "$smartdnsNftsetFile" "$smartdnsNftsetCache" "${compressed_cache_dir}/${smartdnsNftsetGzip}" "$smartdnsNftsetConfig"
-                       rm -f "$unboundFile" "$unboundCache" "${compressed_cache_dir}/${unboundGzip}"
-                       if [ -s "/etc/config/dhcp" ]; then
-                               config_load 'dhcp'
-                               config_foreach _dnsmasq_instance_config 'dnsmasq' 'cleanup'
-                               uci_changes 'dhcp' && uci_commit 'dhcp'
-                       fi
-                       if [ -s "/etc/config/smartdns" ]; then
-                               config_load 'smartdns'
-                               config_foreach _smartdns_instance_config 'smartdns' 'cleanup'
-                               uci_changes 'smartdns' && uci_commit 'smartdns'
-                       fi
-               ;;
-               on_load)
-                       :
-               ;;
-               on_stop|quiet|quiet_restart)
-                       eval "${resolver_name}_restart"
-                       return $?
-               ;;
-               on_start)
-                       if ! adb_file 'test'; then
-                               json set status 'statusFail'
-                               json add error 'errorOutputFileCreate' "$outputFile"
-                               return 1
-                       fi
-                       output 1 "Cycling $resolver_name "
-                       resolver 'update_config' && \
-                       resolver 'test' && \
-                       resolver 'sanity' && \
-                       resolver 'restart' && \
-                       resolver 'heartbeat' || resolver 'revert'
-                       output 1 '\n'
-               ;;
-               test)
-                       case "$dns" in
-                               dnsmasq.*)
-                                       output_dns "Testing $dns configuration "
-                                       if dnsmasq --test >/dev/null 2>/dev/null; then
-                                               output_ok
-                                               return 0
-                                       else
-                                               output_fail
-                                               return 1
-                                       fi
-                               ;;
-                               smartdns.*)
-                                       return 0
-                               ;;
-                               unbound.*)
-                                       return 0
-                               ;;
-                       esac
-               ;;
-               restart)
-                       output_dns "Restarting $resolver_name "
-                       json set message "Restarting $resolver_name"
-                       if eval "${resolver_name}_restart"; then
-                               json set status 'statusSuccess'
-                               led_on "$led"
-                               output_ok
-                               return 0
-                       else 
-                               output_fail
-                               json set status 'statusFail'
-                               json add error 'errorDNSReload'
-                               return 1
-                       fi
-               ;;
-               sanity)
-                       [ -n "$sanity_check" ] || return 0
-                       output_dns "Sanity check for $dns TLDs "
-                       if ! grep -qvE '\.|server:' "$outputFile"; then
-                               output_ok
-                       else
-                               json add warning 'warningSanityCheckTLD' "$outputFile"
-                               output_warn
-                       fi
-                       output_dns "Sanity check for $dns leading dots "
-                       case "$dns" in
-                               dnsmasq.*)
-                                       if ! grep -q '/\.' "$outputFile"; then
-                                               output_ok
-                                       else
-                                               json add warning 'warningSanityCheckLeadingDot' "$outputFile"
-                                               output_warn
-                                       fi
-                               ;;
-                               smartdns.*)
-                                       if ! grep -q '^\.' "$outputFile"; then
-                                               output_ok
-                                       else
-                                               json add warning 'warningSanityCheckLeadingDot' "$outputFile"
-                                               output_warn
-                                       fi
-                               ;;
-                               unbound.*)
-                                       if ! grep -q '"\.' "$outputFile"; then
-                                               output_ok
-                                       else
-                                               json add warning 'warningSanityCheckLeadingDot' "$outputFile"
-                                               output_warn
-                                       fi
-                               ;;
-                       esac
-               ;;
-               heartbeat)
-                       [ -n "$heartbeat_domain" ] || return 0
-                       is_integer "$heartbeat_sleep_timeout" || return 0
-                       output_dns "Probing $heartbeat_domain for $heartbeat_sleep_timeout seconds "
-                       json set message "Testing resolver on $heartbeat_domain"
-                       local i=0
-                       while [ "$i" -lt "$heartbeat_sleep_timeout" ]; do
-                               if resolveip "$heartbeat_domain" >/dev/null 2>&1; then
-                                       output_ok
-                                       return 0
-                               fi
-                               output_dot
-                               i=$((i+1))
-                               sleep 1
-                       done
-                       output_fail
-                       json set status 'statusFail'
-                       json add error 'errorNoHeartbeat'
-                       return 1
-               ;;
-               revert)
-                       output 1 "Resetting/Restarting $resolver_name "
-                       output_dns "Resetting $resolver_name "
-                       resolver 'cleanup'
-                       output_ok
-                       output_dns "Restarting $resolver_name "
-                       if eval "${resolver_name}_restart"; then
-                               led_off "$led"
-                               output_ok
-                               return 0
-                       else
-                               output_fail
-                               json set status 'statusFail'
-                               json add error 'errorDNSReload'
-                               return 1
-                       fi
-               ;;
-               update_config)
-                       output_dns "Updating $resolver_name configuration "
-                       case "$dns" in
-                               dnsmasq.*)
-                                       config_load 'dhcp'
-                                       if [ "$dnsmasq_instance" = "*" ]; then
-                                               config_foreach _dnsmasq_instance_config 'dnsmasq' "$dns"
-                                               config_foreach _dnsmasq_instance_append_force_dns_port 'dnsmasq'
-                                       elif [ -n "$dnsmasq_instance" ]; then
-                                               for i in $dnsmasq_instance; do
-                                                       _dnsmasq_instance_config "@dnsmasq[$i]" "$dns" || _dnsmasq_instance_config "$i" "$dns"
-                                                       _dnsmasq_instance_append_force_dns_port "@dnsmasq[$i]" || _dnsmasq_instance_append_force_dns_port "$i"
-                                               done
-                                       fi
-                                       uci_changes 'dhcp' && uci_commit 'dhcp'
-                                       if adb_file 'test'; then
-                                               chmod 660 "$outputFile"
-                                               chown root:dnsmasq "$outputFile" >/dev/null 2>/dev/null
-                                       else
-                                               json set status 'statusFail'
-                                               json add error 'errorNoOutputFile' "$outputFile"
-                                               return 1
-                                       fi
-                               ;;
-                               smartdns.*)
-                                       config_load 'smartdns'
-                                       if [ "$smartdns_instance" = "*" ]; then
-                                               config_foreach _smartdns_instance_config 'smartdns' "$dns"
-                                               config_foreach _smartdns_instance_append_force_dns_port 'smartdns'
-                                       elif [ -n "$smartdns_instance" ]; then
-                                               for i in $smartdns_instance; do
-                                                       _smartdns_instance_config "@smartdns[$i]" "$dns" || _smartdns_instance_config "$i" "$dns"
-                                                       _smartdns_instance_append_force_dns_port "@smartdns[$i]" || _smartdns_instance_append_force_dns_port "$i"
-                                               done
-                                       fi
-                                       uci_changes 'smartdns' && uci_commit 'smartdns'
-                                       chmod 660 "$outputFile" "$outputConfig"
-                                       chown root:root "$outputFile" "$outputConfig" >/dev/null 2>/dev/null
-                               ;;
-                               unbound.*)
-                                       config_load 'unbound'
-                                       config_foreach _unbound_instance_append_force_dns_port 'unbound'
-                                       chmod 660 "$outputFile"
-                                       chown root:unbound "$outputFile" >/dev/null 2>/dev/null
-                               ;;
-                       esac
-                       output_ok
-               ;;
-       esac
-}
-
-adb_file() {
-       local R_TMP
-       case "$1" in
-               create|backup)
-                       [ -s "$outputFile" ] && { mv -f "$outputFile" "$outputCache"; } >/dev/null 2>/dev/null
-                       return $?
-               ;;
-               restore|use)
-                       [ -s "$outputCache" ] && mv "$outputCache" "$outputFile" >/dev/null 2>/dev/null
-                       return $?
-               ;;
-               test|test_file)
-                       [ -s "$outputFile" ]
-                       return $?
-               ;;
-               test_cache)
-                       [ -s "$outputCache" ]
-                       return $?
-               ;;
-               test_gzip)
-                       [ -s "$outputGzip" ] && gzip -t -c "$outputGzip" >/dev/null 2>/dev/null
-                       return $?
-               ;;
-               create_gzip)
-                       [ -s "$outputFile" ] || return 1
-                       rm -f "$outputGzip" >/dev/null 2>/dev/null
-                       R_TMP="$(mktemp -q -t "${packageName}_tmp.XXXXXXXX")"
-                       if gzip < "$outputFile" > "$R_TMP"; then
-                               if mv "$R_TMP" "$outputGzip"; then
-                                       rm -f "$R_TMP"
-                                       return 0
-                               else
-                                       rm -f "$R_TMP"
-                                       return 1
-                               fi
-                       else
-                               return 1
-                       fi
-               ;;
-               expand|unpack|unpack_gzip)
-                       [ -s "$outputGzip" ] && gzip -dc < "$outputGzip" > "$outputCache"
-                       return $?
-               ;;
-               remove_cache)
-                       rm -f "$outputCache" >/dev/null 2>/dev/null
-               ;;
-               remove_gzip)
-                       rm -f "$outputGzip" >/dev/null 2>/dev/null
-               ;;
-       esac
-}
-
-process_file_url_wrapper() {
-       if [ "$2" != '0' ]; then
-               json add error 'errorConfigValidationFail'
-       fi
-       if [ -n "$parallel_downloads" ]; then
-               process_file_url "$1" &
-       else
-               process_file_url "$1"
-       fi
-}
-
-process_file_url() {
-       _sanitize_source() {
-               local type="$1" file="$2"
-               case "$type" in
-                       hosts)
-                               sed -i '/# Title: StevenBlack/,/# Custom host records are listed here/d' "$file"
-#                              sed -i -E '/^(.*)[\t ](local|localhost|localhost.localdomain)$/d;/^255.255.255.255[\t ]broadcasthost$/d;/^0.0.0.0[\t ]0.0.0.0$/d' "$file"
-#                              sed -i -E '/^(.*)[\t ](ip6-localhost|ip6-loopback|ip6-localnet|ip6-mcastprefix|ip6-allnodes|ip6-allrouters|ip6-allhosts)/d' "$file"
-                       ;;
-               esac
-       }
-# url and action are set by load_validate_file_url_section or passed as 2nd and 3rd parameter
-       local cfg="$1" new_size
-       local label type D_TMP R_TMP filter
-       if [ -z "$cfg" ] || [ -n "${2}${3}" ]; then
-               url="$2"
-               action="$3"
-       fi
-
-       [ "$enabled" = '1' ] || return 0
-       [ -n "$url" ] || return 1
-
-       label="${url##*//}"
-       label="${label%%/*}"
-       label="${name:-$label}"
-       label="List: $label"
-       case "$action" in
-               allow) type='Allowed'; D_TMP="$ALLOWED_TMP"
-               ;;
-               block) type='Blocked'; D_TMP="$B_TMP"
-               ;;
-               file) type='File'; D_TMP="$B_TMP"
-               ;;
-       esac
-       if is_https_url "$url" && [ -z "$isSSLSupported" ]; then
-               output 1 "$_FAIL_"
-               output 2 "[ DL ] $type $label $__FAIL__\n"
-               json add error 'errorNoSSLSupport' "${name:-$url}"
-               return 0
-       fi
-       R_TMP="$(mktemp -q -t "${packageName}_tmp.XXXXXXXX")"
-       if [ -z "$url" ] || ! $dl_command "$url" "$dl_flag" "$R_TMP" 2>/dev/null || \
-               [ ! -s "$R_TMP" ]; then
-               output 1 "$_FAIL_"
-               output 2 "[ DL ] $type $label $__FAIL__\n"
-               json add error 'errorDownloadingList' "${name:-$url}"
-       else
-               append_newline "$R_TMP"
-               [ -n "$cfg" ] && new_size="$(get_local_filesize "$R_TMP")"
-               if [ -n "$new_size" ] && [ "$size" != "$new_size" ]; then
-                       uci_set "$packageName" "$cfg" 'size' "$new_size"
-               fi
-               format="$(detect_file_type "$R_TMP")"
-               case "$format" in
-                       adblockplus) filter="$adBlockPlusFilter";;
-                       dnsmasq) filter="$dnsmasqFileFilter";;
-                       dnsmasq2) filter="$dnsmasq2FileFilter";;
-                       dnsmasq3) filter="$dnsmasq3FileFilter";;
-                       domains) filter="$domainsFilter";;
-                       hosts)
-                               filter="$hostsFilter"
-                               _sanitize_source 'hosts' "$R_TMP"
-                       ;;
-                       *)
-                               output 1 "$_FAIL_"
-                               output 2 "[ DL ] $type $label $__FAIL__\n"
-                               json add error 'errorDetectingFileType' "${name:-$url}"
-                               rm -f "$R_TMP"
-                               return 0
-                       ;;
-               esac
-               if [ -n "$filter" ] && [ "$action" != 'file' ]; then
-                       sed -i "$filter" "$R_TMP"
-               fi
-               if [ ! -s "$R_TMP" ]; then
-                       output 1 "$_FAIL_"
-                       output 2 "[ DL ] $type $label ($format) $__FAIL__\n"
-                       json add error 'errorParsingList' "${name:-$url}"
-               else
-                       append_newline "$R_TMP"
-                       cat "${R_TMP}" >> "$D_TMP"
-                       output 1 "$_OK_"
-                       output 2 "[ DL ] $type $label ($format) $__OK__\n"
-               fi
-       fi
-       rm -f "$R_TMP"
-       return 0
-}
-
-download_dnsmasq_file() {
-       json set message "$(get_text 'statusDownloading')..."
-       json set status 'statusDownloading'
-
-       rm -f "$ALLOWED_TMP" "$A_TMP" "$B_TMP" "$SED_TMP" "$outputFile" "$outputCache" "$outputGzip"
-       if [ "$(get_mem_available)" -lt "$packageMemoryThreshold" ]; then
-               output 'Low free memory, restarting resolver '
-               if resolver 'quiet_restart'; then
-                       output_okn
-               else 
-                       output_failn
-               fi
-       fi
-       touch "$ALLOWED_TMP" "$A_TMP" "$B_TMP" "$SED_TMP"
-       output 1 'Downloading dnsmasq file '
-       process_file_url '' "$dnsmasq_config_file_url" 'file'
-       output_dns 'Moving dnsmasq file '
-       if mv "$B_TMP" "$outputFile"; then
-               output_ok
-       else
-               output_fail
-               json add error 'errorMovingDataFile' "$i"
-       fi
-       output 1 '\n'
-}
-
-download_lists() {
-# shellcheck disable=SC2317,SC2329
-       _ram_check() {
-               _config_calculate_sizes() {
-                       local cfg="$1"
-                       local en size url
-                       config_get_bool en "$cfg" enabled '1'
-                       config_get size "$cfg" size
-                       config_get url "$cfg" url
-                       [ "$en" = '0' ] && return 0
-                       [ -n "$size" ] || size="$(get_url_filesize "$url")"
-                       [ -n "$size" ] && total_sizes=$((total_sizes+size))
-               }
-               local i free_mem total_sizes
-               free_mem="$(get_mem_available)"
-               if [ -z "$free_mem" ]; then
-                       json add warning 'warningFreeRamCheckFail'
-                       output_warning "$(get_text 'warningFreeRamCheckFail')"
-                       return 0
-               fi
-               config_load "$packageName"
-               config_foreach _config_calculate_sizes 'file_url'
-               if [ $((free_mem)) -lt $((total_sizes * 2)) ]; then
-                       json add error 'errorTooLittleRam' "$free_mem"
-                       return 1
-               else
-                       return 0
-               fi
-       }
-       local hf j=0 R_TMP
-       local step_title start_time end_time elapsed
-
-       _ram_check || return 1
-
-       json set message "$(get_text 'statusDownloading')..."
-       json set status 'statusDownloading'
-
-       rm -f "$ALLOWED_TMP" "$A_TMP" "$B_TMP" "$SED_TMP" "$outputFile" "$outputCache" "$outputGzip"
-       if [ "$(get_mem_total)" -lt "$packageMemoryThreshold" ]; then
-               output 'Low free memory, restarting resolver '
-               if resolver 'quiet_restart'; then
-                       output_okn
-               else 
-                       output_failn
-               fi
-       fi
-       touch "$ALLOWED_TMP" "$A_TMP" "$B_TMP" "$SED_TMP"
-       output 1 'Downloading lists '
-       config_load "$packageName"
-       config_foreach load_validate_file_url_section 'file_url' process_file_url_wrapper
-       wait
-       if uci_changes "$packageName"; then
-               output 2 "[PROC] Saving updated file sizes "
-               if [ -n "$update_config_sizes" ] && uci_commit "$packageName"; then output_ok; else output_fail; fi
-       fi
-       output 1 '\n'
-
-       if [ -n "$canary_domains_icloud" ]; then
-               canaryDomains="${canaryDomains:+$canaryDomains }${canaryDomainsiCloud}"
-       fi
-       if [ -n "$canary_domains_mozilla" ]; then
-               canaryDomains="${canaryDomains:+$canaryDomains }${canaryDomainsMozilla}"
-       fi
-
-       output 1 'Processing downloads '
-
-       start_time=$(date +%s)
-       step_title='Sorting combined block-list'
-       output 2 "[PROC] $step_title "
-       json set status 'statusProcessing'
-       json set message "$(get_text 'statusProcessing'): $step_title"
-       append_newline "$B_TMP"
-       {
-               for hf in $blocked_domain $canaryDomains; do
-                       [ -n "$hf" ] && echo "$hf"
-               done
-       } | sed "$domainsFilter" >> "$B_TMP"
-       sed -i '/^[[:space:]]*$/d' "$B_TMP"
-       [ ! -s "$B_TMP" ] && return 1
-
-       if [ -n "$allow_non_ascii" ]; then
-               if sort -u "$B_TMP" > "$A_TMP"; then
-                       output_ok
-               else
-                       output_fail
-                       json add error 'errorSorting'
-               fi
-       else
-               if sort -u "$B_TMP" | grep -E -v '[^a-zA-Z0-9=/.-]' > "$A_TMP"; then
-                       output_ok
-               else
-                       output_fail
-                       json add error 'errorSorting'
-               fi
-       fi
-       end_time=$(date +%s)
-       elapsed=$(( end_time - start_time ))
-       logger_debug "[PERF-DEBUG] ${step_title} took ${elapsed}s"
-
-       case "$dns" in
-               'dnsmasq.conf' | 'dnsmasq.ipset' | 'dnsmasq.nftset' | 'dnsmasq.servers' | \
-               'smartdns.domainset' | 'smartdns.ipset' | 'smartdns.nftset' | \
-               'unbound.adb_list' )
-                       start_time=$(date +%s)
-                       step_title='Optimizing combined block-list'
-                       output 2 "[PROC] ${step_title} "
-                       json set message "$(get_text 'statusProcessing'): ${step_title}"
-# shellcheck disable=SC2016
-                       if $awk -F "." '{for(i=NF;i>0;i--) printf "%s%s", $i, (i>1?".":"\n")}' "$A_TMP" > "$B_TMP"; then
-                               if sort "$B_TMP" > "$A_TMP"; then
-                                       if $awk '
-                                               NR==1 {prev=$0; print; next}
-                                               {
-                                                       len=length(prev)
-                                                       if(substr($0,1,len)==prev && substr($0,len+1,1)==".") next
-                                                       print
-                                                       prev=$0
-                                               }
-                                       ' "$A_TMP" > "$B_TMP"; then
-                                               if $awk -F "." '{for(i=NF;i>0;i--) printf "%s%s", $i, (i>1?".":"\n")}' "$B_TMP" > "$A_TMP"; then
-                                                       if sort -u "$A_TMP" > "$B_TMP"; then
-                                                               output_ok
-                                                       else
-                                                               output_fail
-                                                               json add error 'errorOptimization'
-                                                               mv "$A_TMP" "$B_TMP"
-                                                       fi
-                                               else
-                                                       output_fail
-                                                       json add error 'errorOptimization'
-                                               fi
-                                       else
-                                               output_fail
-                                               json add error 'errorOptimization'
-                                               mv "$A_TMP" "$B_TMP"
-                                       fi
-                               else
-                                       output_fail
-                                       json add error 'errorOptimization'
-                               fi
-                       else
-                               output_fail
-                               json add error 'errorOptimization'
-                               mv "$A_TMP" "$B_TMP"
-                       fi
-                       end_time=$(date +%s)
-                       elapsed=$(( end_time - start_time ))
-                       logger_debug "[PERF-DEBUG] ${step_title} took ${elapsed}s"
-               ;;
-               *)
-                       mv "$A_TMP" "$B_TMP"
-               ;;
-       esac
-
-       if [ -n "$allowed_domain" ] || [ -s "$ALLOWED_TMP" ]; then
-               start_time=$(date +%s)
-               step_title='Removing allowed domains from combined block-list'
-               output 2 "[PROC] ${step_title} "
-               json set message "$(get_text 'statusProcessing'): ${step_title}"
-               local allowed_domains_from_dl
-               [ -s "$ALLOWED_TMP" ] && allowed_domains_from_dl="$(sed '/^[[:space:]]*$/d' "$ALLOWED_TMP")"
-               allowed_domain="${allowed_domain}${allowed_domains_from_dl:+ $allowed_domains_from_dl}"
-               for hf in ${allowed_domain}; do
-                       hf="$(echo "$hf" | sed 's/\./\\./g')"
-                       echo "/(^|\.)${hf}$/d;"
-               done > "$SED_TMP"
-               # if only doing exact matches, may be faster to add $hf to $ALLOWED_TMP and then
-               # grep -vFf "$ALLOWED_TMP" "$B_TMP" > "$A_TMP" && mv "$A_TMP" "$B_TMP"
-               if [ -s "$SED_TMP" ]; then
-                       if sed -E -f "$SED_TMP" "$B_TMP" > "$A_TMP" && mv "$A_TMP" "$B_TMP"; then
-                               output_ok
-                       else
-                               output_fail
-                               json add error 'errorAllowListProcessing'
-                       fi
-               else
-                       output_fail
-                       json add error 'errorAllowListProcessing'
-               fi
-               end_time=$(date +%s)
-               elapsed=$(( end_time - start_time ))
-               logger_debug "[PERF-DEBUG] ${step_title} took ${elapsed}s"
-       fi
-
-       start_time=$(date +%s)
-       step_title='Formatting combined block-list file'
-       output 2 "[PROC] ${step_title} "
-       json set message "$(get_text 'statusProcessing'): ${step_title}"
-       if [ -z "$outputFilterIPv6" ]; then
-               if sed "$outputFilter" "$B_TMP" > "$A_TMP"; then
-                       output_ok
-               else
-                       output_fail
-                       json add error 'errorDataFileFormatting'
-               fi
-       else
-               case "$dns" in
-                       dnsmasq.addnhosts)
-                               if sed "$outputFilter" "$B_TMP" > "$A_TMP" && \
-                                       sed "$outputFilterIPv6" "$B_TMP" >> "$A_TMP"; then
-                                       output_ok
-                               else
-                                       output_fail
-                                       json add error 'errorDataFileFormatting'
-                               fi
-                       ;;
-               esac
-       fi
-       end_time=$(date +%s)
-       elapsed=$(( end_time - start_time ))
-       logger_debug "[PERF-DEBUG] ${step_title} took ${elapsed}s"
-
-       if [ -n "$outputAllowFilter" ] && [ -n "$allowed_domain" ]; then
-               rm -f "$SED_TMP"; touch "$SED_TMP";
-               start_time=$(date +%s)
-               step_title="Explicitly allowing domains in ${dns}"
-               output 2 "[PROC] ${step_title} "
-               json set message "$(get_text 'statusProcessing'): ${step_title}"
-               for hf in ${allowed_domain}; do
-                       echo "$hf" | sed -E "$outputAllowFilter" >> "$SED_TMP"
-               done
-               if [ -s "$SED_TMP" ]; then
-                       if cat "$SED_TMP" "$A_TMP" > "$B_TMP"; then
-                               output_ok
-                       else
-                               output_fail
-                               json add error 'errorAllowListProcessing'
-                       fi
-               else
-                       output_fail
-                       json add error 'errorAllowListProcessing'
-               fi
-               end_time=$(date +%s)
-               elapsed=$(( end_time - start_time ))
-               logger_debug "[PERF-DEBUG] ${step_title} took ${elapsed}s"
-       else
-               mv "$A_TMP" "$B_TMP"
-       fi
-
-       start_time=$(date +%s)
-       step_title="Setting up ${dns} file"
-       output 2 "[PROC] ${step_title} "
-       json set message "$(get_text 'statusProcessing'): ${step_title}"
-
-       case "$dns" in
-               unbound.adb_list)
-                       if mv "$B_TMP" "$outputFile"; then
-                               output_ok
-                       else
-                               output_fail
-                               json add error 'errorMovingDataFile' "$outputFile"
-                       fi
-                       sed -i '1 i\server:' "$outputFile"
-               ;;
-               *)
-                       if mv "$B_TMP" "$outputFile"; then
-                               output_ok
-                       else
-                               output_fail
-                               json add error 'errorMovingDataFile' "$outputFile"
-                       fi
-               ;;
-       esac
-
-       # Validate and remove invalid domain entries (RFC 1123 compliant)
-       if [ -n "$dnsmasq_validity_check" ]; then
-               case "$dns" in
-                       dnsmasq.conf|dnsmasq.ipset|dnsmasq.nftset|dnsmasq.servers|dnsmasq.addnhosts)
-                               start_time=$(date +%s)
-                               step_title='Validating domain entries'
-                               output 2 "[PROC] ${step_title} "
-                               json set message "$(get_text 'statusProcessing'): ${step_title}"
-                               invalid_file="/tmp/${packageName}.invalid.tmp"
-                               rm -f "$invalid_file"
-                               # Fast validation: remove entries where domain:
-                               # - starts with dash or dot (invalid per RFC)
-                               # - is all numeric with dots (IP-like, invalid for domain)
-                               # - has consecutive dots
-                               # - ends with dash or dot (invalid per RFC)
-                               sed "$outputParseFilter" "$outputFile" | \
-                                       grep -E '^-|^\.|^[0-9.]+$|\.\.|-$|\.$' > "$invalid_file" 2>/dev/null || true
-                               if [ -s "$invalid_file" ]; then
-                                       invalid_count=$(wc -l < "$invalid_file" 2>/dev/null || echo 0)
-                                       if [ "$invalid_count" -gt 0 ]; then
-                                               # Create pattern file for grep -vFf (fastest removal method)
-                                               # Use appropriate prefix based on dns type
-                                               case "$dns" in
-                                                       dnsmasq.conf)
-                                                               sed "$dnsmasqConfGrepPattern" "$invalid_file" > "${invalid_file}.pat" 2>/dev/null
-                                                       ;;
-                                                       dnsmasq.ipset)
-                                                               sed "$dnsmasqIpsetGrepPattern" "$invalid_file" > "${invalid_file}.pat" 2>/dev/null
-                                                       ;;
-                                                       dnsmasq.nftset)
-                                                               sed "$dnsmasqNftsetGrepPattern" "$invalid_file" > "${invalid_file}.pat" 2>/dev/null
-                                                       ;;
-                                                       dnsmasq.servers)
-                                                               sed "$dnsmasqServersGrepPattern" "$invalid_file" > "${invalid_file}.pat" 2>/dev/null
-                                                       ;;
-                                                       dnsmasq.addnhosts)
-                                                               # Create patterns for both IPv4 and IPv6 formats
-                                                               { sed "$dnsmasqAddnhostsGrepPatternIPv4" "$invalid_file"; sed "$dnsmasqAddnhostsGrepPatternIPv6" "$invalid_file"; } > "${invalid_file}.pat" 2>/dev/null
-                                                       ;;
-                                               esac
-                                               # Remove invalid entries
-                                               grep -vFf "${invalid_file}.pat" "$outputFile" > "${outputFile}.valid" 2>/dev/null && \
-                                                       mv "${outputFile}.valid" "$outputFile" 2>/dev/null
-                                               # Report (limit to first 20 for performance)
-                                               logger -t "$packageName" "Removed $invalid_count invalid entries from ${dns}."
-                                               json add warning 'warningInvalidDomainsRemoved' "$invalid_count"
-                                               rm -f "${invalid_file}.pat"
-                                       fi
-                                       rm -f "$invalid_file"
-                               fi
-                               if [ "${invalid_count:-0}" -gt 0 ]; then
-                                       output_warn
-                               else
-                                       output_ok
-                               fi
-                               end_time=$(date +%s)
-                               elapsed=$(( end_time - start_time ))
-                               logger_debug "[PERF-DEBUG] ${step_title} took ${elapsed}s"
-                       ;;
-               esac
-       fi
-
-       step_title='Removing temporary files'
-       output 2 "[PROC] ${step_title} "
-       json set message "$(get_text 'statusProcessing'): ${step_title}"
-       if rm -f "/tmp/${packageName}_tmp."* "$ALLOWED_TMP" "$A_TMP" "$B_TMP" "$SED_TMP" "$outputCache"; then
-               output_ok
-       else
-               output_fail
-               json add error 'errorRemovingTempFiles'
-       fi
-       output 1 '\n'
-}
-
-adb_config_update() {
-# shellcheck disable=SC2317,SC2329
-       _cleanup_missing_urls() {
-               local cfg="$1" url size
-               config_get url "$cfg" url
-               if [ -z "$url" ]; then
-                       uci_delete "$packageName" "$cfg"
-               fi
-       }
-       local R_TMP label
-       local param="${1:-quiet}"
-       load_package_config
-       load_dl_command
-       label="${config_update_url##*//}"
-       label="${label%%/*}";
-       [ -n "$enabled" ] || return 0
-       [ -n "$config_update_enabled" ] || return 0
-
-       if [ "$param" != 'download' ]; then
-               adb_file 'test' && return 0 
-               adb_file 'test_cache' && return 0 
-               adb_file 'test_gzip' && return 0 
-       fi
 
-       output 1 'Updating config '
-       output 2 "[ DL ] Config Update: $label "
-       R_TMP="$(mktemp -q -t "${packageName}_tmp.XXXXXXXX")"
-       if ! $dl_command "$config_update_url" "$dl_flag" "$R_TMP" 2>/dev/null || [ ! -s "$R_TMP" ]; then
-               append_newline "$R_TMP"
-               output_failn
-               json add error 'errorDownloadingConfigUpdate'
-       else
-               if [ -s "$R_TMP" ] && sed -f "$R_TMP" -i "$packageConfigFile" 2>/dev/null; then
-                       output_okn
-               else
-                       output_failn
-                       json add error 'errorParsingConfigUpdate'
-               fi
-       fi
-       rm -f "$R_TMP"
-       config_load "$packageName"
-       config_foreach _cleanup_missing_urls 'file_url'
-       uci_changes "$packageName" && uci_commit "$packageName"
-       return 0
-}
-
-# shellcheck disable=SC2120
+boot() { rc_procd start_service 'on_boot' && service_started 'on_boot'; }
 start_service() {
-       local status error param="${1:-on_start}"
-       local action p iface k
-       status="$(json get status)"
-       error="$(json get error)"
-       json del all
-
-       case "$param" in
-               on_boot)
-                       if adb_file 'test_gzip' || adb_file 'test_cache'; then
-                               unset adbf_boot_flag
-                       else
-                               return 0
-                       fi
-               ;;
-       esac
-
-       adb_config_update "$param"
-       load_environment "$param" "$(load_validate_config)" || return 1
-
-       action="$(adb_config_cache get trigger_service)"
-       fw4_restart_flag="$(adb_config_cache get trigger_fw4)"
-
-       if [ -n "$error" ]; then
-               action='download'
-       elif ! adb_file 'test'; then 
-               if adb_file 'test_gzip' || adb_file 'test_cache'; then
-                       action='restore'
-               else
-                       action='download'
-               fi
-       elif [ "$status" = "statusSuccess" ]; then
-               action='skip'
-       fi
-
-       case "${action}:${param}" in
-               on_boot:*|*:on_boot|*:on_pause)
-                       if adb_file 'test_gzip' || adb_file 'test_cache'; then
-                               action='restore'
-                       else
-                               action='download'
-                       fi
-               ;;
-               download:*|*:download)
-                       action='download';;
-               restart:*)
-                       action='restart';;
-               restore:*)
-                       action='restore';;
-               skip:*)
-                       action='skip';;
-               *:*) 
-                       action='download';;
-       esac
-
-       if [ "$action" = 'restore' ]; then
-               output 1 "Starting $serviceName...\n"
-               output 2 "[INIT] Starting $serviceName...\n"
-               json set status 'statusStarting'
-               if adb_file 'test_gzip' && ! adb_file 'test_cache' &&  ! adb_file 'test'; then
-                       output 1 'Found compressed cache file, unpacking it '
-                       output 2 '[INIT] Found compressed cache file, unpacking it '
-                       json set message 'found compressed cache file, unpacking it.'
-                       if adb_file 'unpack_gzip'; then
-                               output_okn
-                       else
-                               output_failn
-                               output_error "$(get_text 'errorRestoreCompressedCache')"
-                               action='download'
-                       fi
-               fi
-               if adb_file 'test_cache' && ! adb_file 'test'; then
-                       output 1 'Found cache file, reusing it '
-                       output 2 '[INIT] Found cache file, reusing it '
-                       json set message 'found cache file, reusing it.'
-                       if adb_file 'restore'; then 
-                               unset sanity_check
-                               unset heartbeat_domain
-                               output_okn
-                               resolver 'on_start'
-                       else
-                               output_failn
-                               output_error "$(get_text 'errorRestoreCache')"
-                               action='download'
-                       fi
-               fi
-       fi
-
-       if [ "$action" = 'download' ]; then
-               if [ -z "$blocked_url" ] && [ -z "$blocked_domain" ]; then
-                       json set status 'statusFail'
-                       json add error 'errorNothingToDo'
-               else
-                       if ! adb_file 'test' || adb_file 'test_cache' || adb_file 'test_gzip'; then
-                               output 1 "Force-reloading $serviceName...\n"
-                               output 2 "[INIT] Force-reloading $serviceName...\n"
-                               json set status 'statusForceReloading'
-                       else
-                               output 1 "Starting $serviceName...\n"
-                               output 2 "[INIT] Starting $serviceName...\n"
-                               json set status 'statusStarting'
-                       fi
-                       resolver 'cleanup'
-                       if [ "$dns" = 'dnsmasq.conf' ] && [ -n "$dnsmasq_config_file_url" ]; then
-                               download_dnsmasq_file
-                       else
-                               download_lists
-                       fi
-                       resolver 'on_start'
-               fi
-       fi
-
-       if [ "$action" = 'restart' ]; then
-               output 1 "Restarting $serviceName...\n"
-               output 2 "[INIT] Restarting $serviceName...\n"
-               json set status 'statusRestarting'
-               unset sanity_check
-               unset heartbeat_domain
-               resolver 'on_start'
-       fi
-
-       if [ "$action" = 'start' ]; then
-               output 1 "Starting $serviceName...\n"
-               output 2 "[INIT] Starting $serviceName...\n"
-               json set status 'statusStarting'
-               unset sanity_check
-               unset heartbeat_domain
-               resolver 'on_start'
-       fi
-
-       if adb_file 'test' && [ "$(json get status)" != "statusFail" ]; then
-               json del message
-               json set status 'statusSuccess'
-               json set stats "$serviceName is blocking $(count_blocked_domains) domains (with ${dns})"
-               status_service 'on_start_success'
-       else
-               json set status 'statusFail'
-               json add error 'errorOhSnap'
-               status_service 'on_start_failure'
-               resolver 'revert'
-       fi
-
-       adb_config_cache 'create'
-
-       procd_open_instance 'main'
-       procd_set_param command /bin/true
-       procd_set_param stdout 1
-       procd_set_param stderr 1
-       procd_open_data
-       json_add_string 'version' "$PKG_VERSION"
-       json_add_string 'status' "$(json get status)"
-       json_add_int            'packageCompat' "$packageCompat"
-       json_add_int            'entries' "$(count_blocked_domains)"
-       json_add_array 'errors'
-               for k in $(json get errors); do
-                       json_add_object "$k"
-                       json_add_string 'code' "$(json get error "$k" 'code')"
-                       json_add_string 'info' "$(json get error "$k" 'info')"
-                       json_close_object
-               done
-       json_close_array
-       json_add_array 'warnings'
-               for k in $(json get warnings); do
-                       json_add_object "$k"
-                       json_add_string 'code' "$(json get warning "$k" 'code')"
-                       json_add_string 'info' "$(json get warning "$k" 'info')"
-                       json_close_object
-               done
-       json_close_array
-       json_add_array firewall
-       if [ -n "$force_dns" ]; then
-# shellcheck disable=SC3060
-               for p in ${force_dns_port/,/ }; do
-                       if is_port_listening "$p"; then
-                               for iface in $force_dns_interface; do
-                                       json_add_object ''
-                                       json_add_string type 'redirect'
-                                       json_add_string target 'DNAT'
-                                       json_add_string src "$iface"
-                                       json_add_string proto 'tcp udp'
-                                       json_add_string src_dport '53'
-                                       json_add_string dest_port "$p"
-                                       json_add_string family 'any'
-                                       json_add_boolean reflection '0'
-                                       json_close_object
-                               done
-                       else
-                               for iface in $force_dns_interface; do
-                                       json_add_object ''
-                                       json_add_string type 'rule'
-                                       json_add_string src "$iface"
-                                       json_add_string dest '*'
-                                       json_add_string proto 'tcp udp'
-                                       json_add_string dest_port "$p"
-                                       json_add_string target 'REJECT'
-                                       json_close_object
-                               done
-                       fi
-               done
-       fi
-       case "$dns" in
-               dnsmasq.ipset|smartdns.ipset)
-                       json_add_object ''
-                       json_add_string type 'ipset'
-                       json_add_string name 'adb'
-                       json_add_string match 'dest_net'
-                       json_add_string storage 'hash'
-                       json_close_object
-                       for iface in $force_dns_interface; do
-                               json_add_object ''
-                               json_add_string type 'rule'
-                               json_add_string ipset 'adb'
-                               json_add_string src "$iface"
-                               json_add_string dest '*'
-                               json_add_string proto 'tcp udp'
-                               json_add_string target 'REJECT'
-                               json_close_object
-                       done
-               ;;
-               dnsmasq.nftset|smartdns.nftset)
-                       json_add_object ''
-                       json_add_string type 'ipset'
-                       json_add_string name 'adb4'
-                       json_add_string family '4'
-                       json_add_string match 'dest_net'
-                       json_close_object
-                       for iface in $force_dns_interface; do
-                               json_add_object ''
-                               json_add_string type 'rule'
-                               json_add_string ipset 'adb4'
-                               json_add_string src "$iface"
-                               json_add_string dest '*'
-                               json_add_string proto 'tcp udp'
-                               json_add_string target 'REJECT'
-                               json_close_object
-                       done
-                       if [ -n "$ipv6_enabled" ]; then
-                               json_add_object ''
-                               json_add_string type 'ipset'
-                               json_add_string name 'adb6'
-                               json_add_string family '6'
-                               json_add_string match 'dest_net'
-                               json_close_object
-                               for iface in $force_dns_interface; do
-                                       json_add_object ''
-                                       json_add_string type 'rule'
-                                       json_add_string ipset 'adb6'
-                                       json_add_string src "$iface"
-                                       json_add_string dest '*'
-                                       json_add_string proto 'tcp udp'
-                                       json_add_string target 'REJECT'
-                                       json_close_object
-                               done
-                       fi
-               ;;
-       esac
-       json_close_array
-       procd_close_data
-       procd_close_instance
-       return 0
-}
-
-status_service() {
-       local param="$1"
-       local c status message error warning stats text
-       local code info
-       load_package_config
-       status="$(json get status)"
-       message="$(json get message)"
-       error="$(json get error)"
-       warning="$(json get warning)"
-       stats="$(json get stats)"
-       if [ "$status" = "statusSuccess" ]; then
-               output 1 "* $stats\n"
-               output 2 "[STAT] $stats\n"
-       else
-               [ -n "$status" ] && status="$(get_text "$status")"
-               status="${status}${status:+${message:+: $message}}"
-               case "$(adb_file 'test_cache'; echo $?:$(adb_file 'test_gzip'; echo $?))" in
-                       "0:0")
-                               message="cache file and compressed cache file found"
-                               ;;
-                       "0:1")
-                               message="cache file found"
-                               ;;
-                       "1:0")
-                               message="compressed cache file found"
-                               ;;
-                       *)
-                               unset message
-                               ;;
-               esac
-               status="${status}${status:+${message:+ ($message)}}"
-       [ -n "$status" ] && output "$serviceName $status.\n"
-       fi
-       [ "$param" != 'quiet' ] || return 0
-       if [ -n "$error" ]; then
-               for c in $error; do
-                       code="$(json get error "$c" 'code')"
-                       info="$(json get error "$c" 'info')"
-                       output_error "$(get_text "$code" "$info")"
-               done
-       fi
-       if [ -n "$warning" ]; then
-               for c in $warning; do
-                       code="$(json get warning "$c" 'code')"
-                       info="$(json get warning "$c" 'info')"
-                       output_warning "$(get_text "$code" "$info")"
-               done
-       fi
-}
-
-# shellcheck disable=SC2120
-stop_service() {
-       load_package_config
-       if adb_file 'test'; then
-               output 1 "Stopping $serviceName... "
-               output 2 "[STOP] Stopping $serviceName... "
-               adb_file 'create'
-               if resolver 'on_stop'; then
-                       ipset -q -! flush adb > /dev/null 2>&1
-                       ipset -q -! destroy adb > /dev/null 2>&1
-                       nft delete set inet fw4 adb4 > /dev/null 2>&1
-                       nft delete set inet fw4 adb6 > /dev/null 2>&1
-                       led_off "$led"
-                       output_okn
-                       json set status 'statusStopped'
-                       json del message
-               else 
-                       output_failn;
-                       json set status 'statusFail'
-                       json add error 'errorStopping'
-                       output_error "$(get_text 'errorStopping')"
-               fi
-       fi
-       return 0
-}
-
-boot() {
-#      ubus -t 30 wait_for network.interface 2>/dev/null
-       adbf_boot_flag=1
-       rc_procd start_service 'on_boot' && service_started 'on_boot'
+       local param="${1:-on_start}"
+       _procd_svc_data="$($_ucode start "$param")"
 }
+service_data() { [ -n "$_procd_svc_data" ] && eval "$_procd_svc_data"; }
+stop_service() { eval "$($_ucode stop)"; }
 reload_service() { rc_procd start_service 'reload'; }
 restart_service() { rc_procd start_service 'restart'; }
-service_stopped() { is_fw4_restart_needed && procd_set_config_changed firewall; }
+status_service() { $_ucode status "$@"; }
+service_stopped() { [ "$_fw4_restart" = 1 ] && procd_set_config_changed firewall; }
+service_started() { [ "$_fw4_restart" = 1 ] && procd_set_config_changed firewall; }
 service_triggers() {
-       local wan wan6 i
-       if [ -n "$adbf_boot_flag" ]; then
-               output 1 'Setting trigger (on_boot) '
-               output 2 '[TRIG] Setting trigger (on_boot) '
-               procd_add_raw_trigger "interface.*.up" 5000 "/etc/init.d/${packageName}" start && output_okn || output_failn
-               triggerStatus='statusTriggerBootWait'
-       else
-       procd_open_validate
-               load_validate_file_url_section
-       procd_close_validate
+       local wan wan6 procd_trigger_wan6 i
+       if [ ! -s "/dev/shm/${packageName}" ]; then
+               procd_add_raw_trigger "interface.*.up" 5000 "/etc/init.d/${packageName}" start
+       else            
                network_flush_cache
                network_find_wan wan
                wan="${wan:-wan}"
-               if [ -n "$procd_trigger_wan6" ]; then
+               procd_trigger_wan6="$(uci -q get "${packageName}.config.procd_trigger_wan6")"
+               if [ "$procd_trigger_wan6" = '1' ]; then
                        network_find_wan6 wan6
                        wan6="${wan6:-wan6}"
                fi
-               output 1 "Setting trigger${wan6:+s} for $wan ${wan6:+$wan6 }"
-               output 2 "[TRIG] Setting trigger${wan6:+s} for $wan ${wan6:+$wan6 }"
                for i in $wan $wan6; do
-                       procd_add_interface_trigger "interface.*" "$i" "/etc/init.d/${packageName}" start && output_ok || output_fail
+                       procd_add_interface_trigger "interface.*" "$i" "/etc/init.d/${packageName}" start "on_${i}"
                done
-               output 1 '\n'
                procd_add_config_trigger "config.change" "$packageName" "/etc/init.d/${packageName}" reload
-               triggerStatus='statusTriggerStartWait'
-       fi
-}
-
-service_started() {
-       local start_time end_time elapsed step_title
-       if [ -n "$compressed_cache" ] && ! adb_file 'test_gzip' && adb_file 'test'; then
-               start_time=$(date +%s)
-               step_title="Creating ${dns} compressed cache"
-               output 1 "${step_title} "
-               output 2 "[PROC] ${step_title} "
-               json set message "$(get_text 'statusProcessing'): ${step_title}"
-               if adb_file 'create_gzip'; then
-                       output_okn
-               else
-                       output_failn
-                       json add error 'errorCreatingCompressedCache'
-               fi
-               end_time=$(date +%s)
-               elapsed=$(( end_time - start_time ))
-               logger_debug "[PERF-DEBUG] ${step_title} took ${elapsed}s"
-       else
-               adb_file 'remove_gzip'
-       fi
-       is_fw4_restart_needed && procd_set_config_changed firewall
-       [ -z "$(json get status)" ] && json set status "$triggerStatus"
-}
-
-allow() {
-       local c hf string="$1"
-       load_package_config
-       if ! adb_file 'test'; then
-               output "No block-list ('$outputFile') found.\n"
-               return 0
-       elif [ -z "$string" ]; then
-               output "Usage: /etc/init.d/${packageName} allow 'domain' ...\n"
-               return 0
-       elif [ -n "$dnsmasq_config_file_url" ]; then
-               output "Allowing individual domains is not possible when using external dnsmasq config file.\n"
-               return 0
-       fi
-       case "$dns" in
-               dnsmasq.*)
-                       output 1 'Allowing domains and restarting dnsmasq '
-                       output 2 '[PROC] Allowing domains \n'
-                       for c in $string; do
-                               output 2 "  $c "
-                               hf="$(echo "$c" | sed 's/\./\\./g')"
-                               if sed -i "\:\(/\|\.\)${hf}/:d" "$outputFile"; then
-                                               output_ok
-                               else
-                                       output_fail
-                               fi
-                               if [ -n "$outputAllowFilter" ]; then
-                                       if echo "$c" | sed -E "$outputAllowFilter" >> "$outputFile"; then
-                                                       output_ok
-                                       else
-                                               output_fail
-                                       fi
-                               fi
-                               if uci_add_list_if_new "${packageName}" 'config' 'allowed_domain' "$c"; then
-                                               output_ok
-                               else
-                                       output_fail
-                               fi
-                       done
-                       if [ -n "$compressed_cache" ]; then
-                               output 2 '[PROC] Creating compressed cache '
-                               if adb_file 'create_gzip'; then
-                                       output_ok
-                               else
-                                       output_fail
-                               fi
-                       fi
-                       output 2 '[PROC] Committing changes to config '
-                       if uci_commit "$packageName"; then
-                               allowed_domain="$(uci_get "$packageName" 'config' 'allowed_domain')"
-                               adb_config_cache 'create'
-                               json set stats "$serviceName is blocking $(count_blocked_domains) domains (with ${dns})"
-                               output_ok
-                               if [ "$dns" = 'dnsmasq.ipset' ]; then
-                                       output 2 '[PROC] Flushing adb ipset '
-                                       if ipset -q -! flush adb; then output_ok; else output_fail; fi
-                               fi
-                               if [ "$dns" = 'dnsmasq.nftset' ]; then
-                                       output 2 '[PROC] Flushing adb nft sets '
-                                       nft flush set inet fw4 adb6
-                                       if nft flush set inet fw4 adb4; then output_ok; else output_fail; fi
-                               fi
-                               output_dns 'Restarting dnsmasq '
-                               if dnsmasq_restart; then output_ok; else output_fail; fi
-                       else
-                               output_fail
-                       fi
-                       output 1 '\n'
-               ;;
-               smartdns.*)
-                       output 1 'Allowing domains and restarting smartdns '
-                       output 2 '[PROC] Allowing domains \n'
-                       for c in $string; do 
-                               output 2 "  $c "
-                               hf="$(echo "$c" | sed 's/\./\\./g')"
-                               if sed -i "\:\(\"\|\.\)${hf}\":d" "$outputFile" && \
-                                       uci_add_list_if_new "$packageName" 'config' 'allowed_domain' "$string"; then
-                                               output_ok
-                               else
-                                       output_fail
-                               fi
-                       done
-                       if [ -n "$compressed_cache" ]; then
-                               output 2 '[PROC] Creating compressed cache '
-                               if adb_file 'create_gzip'; then
-                                       output_ok
-                               else
-                                       output_fail
-                               fi
-                       fi
-                       output 2 '[PROC] Committing changes to config '
-                       if uci_commit "$packageName"; then
-                               allowed_domain="$(uci_get "$packageName" 'config' 'allowed_domain')"
-                               adb_config_cache 'create'
-                               json set stats "$serviceName is blocking $(count_blocked_domains) domains (with ${dns})"
-                               output_ok; 
-                               output_dns 'Restarting SmartDNS '
-                               if smartdns_restart; then output_ok; else output_fail; fi
-                       else 
-                               output_fail
-                       fi
-                       output 1 '\n'
-               ;;
-               unbound.*)
-                       output 1 'Allowing domains and restarting Unbound '
-                       output 2 '[PROC] Allowing domains \n'
-                       for c in $string; do 
-                               output 2 "  $c "
-                               hf="$(echo "$c" | sed 's/\./\\./g')"
-                               if sed -i "\:\(\"\|\.\)${hf}\":d" "$outputFile" && \
-                                       uci_add_list_if_new "$packageName" 'config' 'allowed_domain' "$string"; then
-                                               output_ok
-                               else
-                                       output_fail
-                               fi
-                       done
-                       if [ -n "$compressed_cache" ]; then
-                               output 2 '[PROC] Creating compressed cache '
-                               if adb_file 'create_gzip'; then
-                                       output_ok
-                               else
-                                       output_failn
-                               fi
-                       fi
-                       output 2 '[PROC] Committing changes to config '
-                       if uci_commit "$packageName"; then
-                               allowed_domain="$(uci_get "$packageName" 'config' 'allowed_domain')"
-                               adb_config_cache 'create'
-                               json set stats "$serviceName is blocking $(count_blocked_domains) domains (with ${dns})"
-                               output_ok; 
-                               output_dns 'Restarting Unbound '
-                               if unbound_restart; then output_ok; else output_fail; fi
-                       else
-                               output_fail
-                       fi
-                       output 1 '\n'
-               ;;
-       esac
-}
-
-check() {
-       local c param="$1"
-       load_package_config
-       if ! adb_file 'test'; then
-               output "No block-list ('$outputFile') found.\n"
-               return 0
-       elif [ -z "$param" ]; then
-               output "Usage: /etc/init.d/${packageName} check 'domain' ...\n"
-               return 0
-       fi
-       for string in ${param}; do
-               c="$(grep -c -E "$string" "$outputFile")"
-               if [ "$c" -gt 0 ]; then
-                       if [ "$c" -eq 1 ]; then
-                               output 1 "Found 1 match for '$string' in '$outputFile'.\n"
-                               output 2 "[PROC] Found 1 match for '$string' in '$outputFile'.\n"
-                       else
-                               output 1 "Found $c matches for '$string' in '$outputFile'.\n"
-                               output 2 "[PROC] Found $c matches for '$string' in '$outputFile'.\n"
-                       fi
-                       if [ "$c" -le 20 ]; then
-                               grep "$string" "$outputFile" | sed "$outputParseFilter"
-                       fi
-               else
-                       output 1 "The '$string' is not found in current block-list ('$outputFile').\n"
-                       output 2 "[PROC] The '$string' is not found in current block-list ('$outputFile').\n"
-               fi
-       done
-}
-
-check_tld() {
-       local c param="$1"
-       load_package_config
-       if ! adb_file 'test'; then
-               output "No block-list ('$outputFile') found.\n"
-               return 0
-       fi
-       c="$(grep -cvE '\.|server:' "$outputFile")"
-       if [ "$c" -gt 0 ]; then
-               if [ "$c" -eq 1 ]; then
-                       output 1 "Found 1 match for TLD in '$outputFile'.\n"
-                       output 2 "[PROC] Found 1 match for TLD in '$outputFile'.\n"
-               else
-                       output 1 "Found $c matches for TLDs in '$outputFile'.\n"
-                       output 2 "[PROC] Found $c matches for TLDs in '$outputFile'.\n"
-               fi
-               if [ "$c" -le 20 ]; then
-                       grep -vE '\.|server:' "$outputFile" | sed "$outputParseFilter"
-               fi
-       else
-               output 1 "No TLD was found in current block-list ('$outputFile').\n"
-               output 2 "[PROC] No TLD was found in current block-list ('$outputFile').\n"
-       fi
-}
-
-check_leading_dot() {
-       local c param="$1"
-       local string
-       load_package_config
-       if ! adb_file 'test'; then
-               output "No block-list ('$outputFile') found.\n"
-               return 0
-       fi
-       case "$dns" in
-               dnsmasq.*)      string='/\.';;
-               smartdns.*)     string='^\.';;
-               unbound.*)      string='"\.';;
-       esac
-       c="$(grep -c "$string" "$outputFile")"
-       if [ "$c" -gt 0 ]; then
-               if [ "$c" -eq 1 ]; then
-                       output 1 "Found 1 match for leading-dot domain in '$outputFile'.\n"
-                       output 2 "[PROC] Found 1 match for leading-dot domain in '$outputFile'.\n"
-               else
-                       output 1 "Found $c matches for leading-dot domains in '$outputFile'.\n"
-                       output 2 "[PROC] Found $c matches for leading-dot domains in '$outputFile'.\n"
-               fi
-               if [ "$c" -le 20 ]; then
-                       grep "$string" "$outputFile" | sed "$outputParseFilter"
-               fi
-       else
-               output 1 "No leading-dot domain was found in current block-list ('$outputFile').\n"
-               output 2 "[PROC] No leading-dot domain was found in current block-list ('$outputFile').\n"
-       fi
-}
-
-check_lists() {
-# shellcheck disable=SC2317,SC2329
-       _check_list() {
-               local cfg="$1"
-               local en size url name R_TMP string c
-               config_get_bool en "$cfg" enabled '1'
-               config_get action "$cfg" action 'block'
-               config_get url "$cfg" url
-               config_get name "$cfg" name
-               name="${name:-$url}"
-
-               [ "$en" = '0' ] && return 0
-               [ "$action" != 'block' ] && return 0
-
-               output 1 "Checking ${name}: "
-               output 2 "[ DL ] $name "
-
-               if is_https_url "$url" && [ -z "$isSSLSupported" ]; then
-                       output_failn
-                       return 1
-               fi
-               R_TMP="$(mktemp -q -t "${packageName}_tmp.XXXXXXXX")"
-               if [ -z "$url" ] || ! $dl_command "$url" "$dl_flag" "$R_TMP" 2>/dev/null || \
-                       [ ! -s "$R_TMP" ]; then
-                       output_failn
-                       return 1
-               else
-                       output 2 "$__OK__\n"
-               fi
-               append_newline "$R_TMP"
-               for string in ${param}; do
-                       c="$(grep -c -E "$string" "$R_TMP")"
-                       if [ "$c" -gt 0 ]; then
-                               if [ "$c" -eq 1 ]; then
-                                       output 1 "found 1 match for '$string'.\n"
-                                       output 2 "[PROC] Found 1 match for '$string' in '$url'.\n"
-                               else
-                                       output 1 "found $c matches for '$string'.\n"
-                                       output 2 "[PROC] Found $c matches for '$string' in '$url'.\n"
-                               fi
-                               grep "$string" "$R_TMP"
-                       else
-                               output 1 "'$string' not found.\n"
-                               output 2 "[PROC] The '$string' is not found in '$url'.\n"
-                       fi
-               done
-       rm -f "$R_TMP"
-       }
-       local param="$1"
-       load_package_config
-       load_dl_command
-       if [ -z "$param" ]; then
-               output "Usage: /etc/init.d/${packageName} check_lists 'domain' ...\n"
-               return 0
-       fi
-       config_load "$packageName"
-       config_foreach _check_list 'file_url'
-       return 0
-}
-
-dl() { rc_procd start_service 'download' && service_started 'download'; }
-
-killcache() {
-       load_package_config
-       rm -f "$dnsmasqAddnhostsCache" "${compressed_cache_dir}/${dnsmasqAddnhostsGzip}"
-       rm -f "$dnsmasqConfCache" "${compressed_cache_dir}/${dnsmasqConfGzip}"
-       rm -f "$dnsmasqIpsetCache" "${compressed_cache_dir}/${dnsmasqIpsetGzip}"
-       rm -f "$dnsmasqNftsetCache" "${compressed_cache_dir}/${dnsmasqNftsetGzip}"
-       rm -f "$dnsmasqServersCache" "${compressed_cache_dir}/${dnsmasqServersGzip}"
-       rm -f "$smartdnsDomainSetCache" "${compressed_cache_dir}/${smartdnsDomainSetGzip}"
-       rm -f "$smartdnsIpsetCache" "${compressed_cache_dir}/${smartdnsIpsetGzip}"
-       rm -f "$smartdnsNftsetCache" "${compressed_cache_dir}/${smartdnsNftsetGzip}"
-       rm -f "$unboundCache" "${compressed_cache_dir}/${unboundGzip}"
-       resolver 'cleanup'
-       return 0
-}
-
-pause() {
-       load_package_config
-       local timeout="${1:-$pause_timeout}"
-       stop_service 'on_pause'
-       output 1 "Sleeping for $timeout seconds... "
-       output 2 "[PROC] Sleeping for $timeout seconds... "
-       if is_integer "$timeout" && sleep "$timeout"; then
-               output_okn
-       else
-               output_failn
-       fi
-       start_service 'on_pause'
-}
-
-show_blocklist() {
-       load_package_config
-       sed "$stripToDomainsFilter" "$outputFile"
-}
-
-sizes() {
-# shellcheck disable=SC2329
-       _config_add_url_size() {
-               local cfg="$1" url name size
-               config_get url "$cfg" url
-               config_get name "$cfg" name
-               size="$(get_url_filesize "$url")"
-               output "${name:-$url}${size:+: $size} "
-               if [ -n "$size" ]; then
-                       uci_set "$packageName" "$cfg" 'size' "$size"
-                       output_okn
-               else
-                       output_failn
-               fi
-       }
-       local i
-       load_package_config
-       load_dl_command
-       config_load "$packageName"
-       config_foreach _config_add_url_size 'file_url'
-       [ -n "$update_config_sizes" ] && uci_changes "$packageName" && uci_commit "$packageName"
-}
-
-version() { echo "$PKG_VERSION"; }
+               procd_open_validate
+                       load_validate_config
+                       load_validate_file_url_section
+               procd_close_validate
+       fi
+}
+allow()             { $_ucode allow "$@"; }
+check()             { $_ucode check "$@"; }
+check_tld()         { $_ucode check_tld "$@"; }
+check_leading_dot() { $_ucode check_leading_dot "$@"; }
+check_lists()       { $_ucode check_lists "$@"; }
+dl()                { rc_procd start_service 'download' && service_started; }
+killcache()         { $_ucode killcache "$@"; }
+pause()             { $_ucode pause "$@"; }
+show_blocklist()    { $_ucode show_blocklist "$@"; }
+sizes()             { $_ucode sizes "$@"; }
+version()           { $_ucode version "$@"; }
 
 # shellcheck disable=SC2120
 load_validate_file_url_section() {
-       uci_load_validate "$packageName" "$packageName" "$1" "$2" \
+       uci_load_validate "$packageName" 'file_url' "$1" "$2" \
                'enabled:bool:1' \
                'action:or("allow", "block"):block' \
                'size:or(uinteger, "")' \
@@ -2703,8 +89,9 @@ load_validate_file_url_section() {
        ;
 }
 
+# shellcheck disable=SC2120
 load_validate_config() {
-       uci_load_validate "$packageName" "$packageName" "$1" "${2}${3:+ $3}" \
+       uci_load_validate "$packageName" "$packageName" "$1" "${2}${3:+ ${3}}" \
                'enabled:bool:0' \
                'force_dns:bool:1' \
                'force_dns_interface:list(network):lan' \
@@ -2730,8 +117,8 @@ load_validate_config() {
                'procd_boot_wan_timeout:integer:60' \
                'led:or("", "none", file, device, string)' \
                'dns:or("dnsmasq.addnhosts", "dnsmasq.conf", "dnsmasq.ipset", "dnsmasq.nftset", "dnsmasq.servers", "smartdns.domainset", "smartdns.ipset", "smartdns.nftset", "unbound.adb_list"):dnsmasq.servers' \
-               'dnsmasq_instance:list(or(integer, string)):*' \
-               'smartdns_instance:list(or(integer, string)):*' \
+               'dnsmasq_instance:list(or("*", "-", uinteger, uci("dhcp", "@dnsmasq"))):*' \
+               'smartdns_instance:list(or("*", "-", uinteger, uci("smartdns", "@smartdns"))):*' \
                'heartbeat_domain:or("-", string):heartbeat.melmac.ca' \
                'heartbeat_sleep_timeout:range(1,60):10' \
                'dnsmasq_sanity_check:bool:1' \
index 2414540029030af7acc38e436b1bcf95c0b4bb4c..0e2cd03312c56ea35b6df23772fa7495fd935076 100644 (file)
 #!/bin/sh
-# Copyright 2023 MOSSDeF, Stan Grishin (stangri@melmac.ca)
-# shellcheck disable=SC2015,SC3043,SC3060
+# Copyright 2023-2026 MOSSDeF, Stan Grishin (stangri@melmac.ca)
+# shellcheck disable=SC2015,SC3043
 
+readonly pkg='adblock-fast'
 
-readonly adbFunctionsFile='/etc/init.d/adblock-fast'
-if [ -s "$adbFunctionsFile" ]; then
-# shellcheck source=../../etc/init.d/adblock-fast
-       . "$adbFunctionsFile"
-else
-       printf "%b: adblock-fast init.d file (%s) not found! \n" '\033[0;31mERROR\033[0m' "$adbFunctionsFile"
-fi
-
-# Transition from simple-adblock
-_enable_url() {
-       local cfg="$1" url="$2" action="$3"
-       local u a
-       config_get u "$cfg" 'url'
-       config_get a "$cfg" 'action' 'block'
-       if [ "$u" = "$url" ] && [ "$a" = "$action" ]; then
-               uci_remove "$packageName" "$cfg" 'enabled' && _found=1
-       fi
-}
+# ── Transition to list names ─────────────────────────────────────────
+# Adds 'name' to file_url sections that lack one, using the pristine default config
 
-enable_add_url() {
-       local url="$1" action="$2" _found
-       config_load "$packageName"
-       config_foreach _enable_url 'file_url' "$url" "$action"
-       if [ -z "$_found" ]; then
-               uci_add "$packageName" 'file_url'
-               uci_set "$packageName" '@file_url[-1]' 'url' "$url"
-               uci_set "$packageName" '@file_url[-1]' 'size' "$(get_url_filesize "$url")"
-               uci_set "$packageName" '@file_url[-1]' 'action' "$action"
-       fi
-}
+# Find pristine default: apk uses .apk-new, opkg uses -opkg
+pristine=''
+for f in "/etc/config/${pkg}.apk-new" "/etc/config/${pkg}-opkg"; do
+       [ -s "$f" ] && pristine="$f" && break
+done
 
-if [ -s '/etc/config/simple-adblock' ] \
-       && [ ! -s '/etc/config/adblock-fast-opkg' ] \
-       && [ "$(uci_get adblock-fast config enabled)" = '0' ]; then
-       cp -f '/etc/config/adblock-fast' '/etc/config/adblock-fast-opkg'
-       enabled="$(uci_get simple-adblock config enabled)"
-       if [ -x '/etc/init.d/simple-adblock' ]; then
-               output "Stopping and disabling simple-adblock "
-               if /etc/init.d/simple-adblock stop  >/dev/null 2>&1 \
-                       && /etc/init.d/simple-adblock disable \
-                       && uci_set simple-adblock config enabled 0 \
-                       && uci_commit simple-adblock; then
-                       output_okn
-               else
-                       output_failn
-               fi
-       else
-               output "Disabling simple-adblock."
-               if uci_set simple-adblock config enabled 0 \
-                       && uci_commit simple-adblock; then
-                       output_okn
-               else
-                       output_failn
-               fi
-       fi
-       output "Migrating simple-adblock config file "
-       for i in allow_non_ascii canary_domains_icloud canary_domains_mozilla \
-               compressed_cache compressed_cache_dir config_update_enabled \
-               curl_additional_param curl_max_file_size curl_retry download_timeout \
-               debug dns dns_instance dnsmasq_config_file_url force_dns led \
-               parallel_downloads procd_trigger_wan6 procd_boot_wan_timeout verbosity; do
-               j="$(uci_get simple-adblock.config.${i})"
-               [ -n "$j" ] && uci_set "$packageName" config "$i" "$j"
-       done
-       [ -n "$enabled" ] && uci_set "$packageName" config enabled "$enabled"
-       j="$(uci_get simple-adblock config config_update_url)"
-       if [ "${j//simple-adblock/}" = "$j" ]; then
-               uci_set "$packageName" config config_update_url "$j"
-       fi
-       ccd="$(uci_get simple-adblock config compressed_cache_dir '/etc')"
-       for j in $(uci_get simple-adblock config allowed_domain); do
-               [ -n "$j" ] && uci_add_list "$packageName" config allowed_domain "$j"
-       done
-       for j in $(uci_get simple-adblock config blocked_domain); do
-               [ -n "$j" ] && uci_add_list "$packageName" config blocked_domain "$j"
-       done
-       for j in $(uci_get simple-adblock config force_dns_port); do
-               [ -n "$j" ] && uci_add_list "$packageName" config force_dns_port "$j"
-       done
-       output_okn
+_find_name() { grep -B1 "$1" "$pristine" 2>/dev/null | head -1 | cut -d "'" -f2; }
 
-       for i in allowed_domains_url blocked_adblockplus_url blocked_domains_url \
-               blocked_hosts_url; do
-               output "Migrating simple-adblock ${i} "
-               for j in $(uci_get simple-adblock config "$i"); do
-                       if [ "$i" = 'allowed_domains_url' ]; then
-                               enable_add_url "$j" 'allow'
-                       else
-                               enable_add_url "$j" 'block'
+if [ -n "$pristine" ]; then
+       # shellcheck disable=SC1091
+       . /lib/functions.sh
+       add_name() {
+               local cfg="$1" url name label
+               config_get url "$cfg" 'url'
+               config_get name "$cfg" 'name'
+               if [ -z "$name" ]; then
+                       label="${url##*//}"; label="${label%%/*}"
+                       name="$(_find_name "$url")"
+                       if [ -n "$name" ]; then
+                               uci set "${pkg}.${cfg}.name=${name}"
+                               printf "  %s: %s\n" "$label" "$name" >&2
                        fi
-               done
-               output_okn
-       done
-       uci_commit "$packageName"
-       output "Migrating simple-adblock cache file(s) "
-       for i in '/var/run/simple-adblock/dnsmasq.addnhosts.cache' \
-               '/var/run/simple-adblock/dnsmasq.conf.cache' \
-               '/var/run/simple-adblock/dnsmasq.ipset.cache' \
-               '/var/run/simple-adblock/dnsmasq.nftset.cache' \
-               '/var/run/simple-adblock/dnsmasq.servers.cache' \
-               '/var/run/simple-adblock/unbound.cache'; do
-               if [ -s "$i" ]; then
-                       current_dir="$(dirname "$i")"
-                       mkdir -p "${current_dir//simple-adblock/adblock-fast}"
-                       mv -f "$i" "${i//simple-adblock/adblock-fast}" && output_okn || output_failn
-               fi
-       done
-       for i in 'simple-adblock.dnsmasq.addnhosts.gz' \
-               'simple-adblock.dnsmasq.conf.gz' \
-               'simple-adblock.dnsmasq.ipset.gz' \
-               'simple-adblock.dnsmasq.nftset.gz' \
-               'simple-adblock.dnsmasq.servers.gz' \
-               'simple-adblock.unbound.gz'; do
-               i="${ccd}/${i}"
-               if [ -s "$i" ]; then
-                       mkdir -p "${ccd//simple-adblock/adblock-fast}"
-                       mv -f "$i" "${i//simple-adblock/adblock-fast}" && output_okn || output_failn
-               fi
-       done
-       output_okn
-fi
-
-# Transition to list names
-_find_name() { grep -B1 "$1" "/etc/config/${packageName}-opkg" | head -1 | cut -d "'" -f2; }
-
-add_name() {
-       local cfg="$1"
-       local url name label
-       config_get url "$cfg" 'url'
-       config_get name "$cfg" 'name'
-       if [ -z "$name" ]; then
-               label="${url##*//}"
-               label="${label%%/*}";
-               output "Finding name for ${label}: "
-               name="$(_find_name "$url")"
-               if [ -n "$name" ]; then
-                       uci_set "$packageName" "$cfg" 'name' "$name"
-                       output "$name "
-                       output_okn
-               else
-                       output "Unknown "
-                       output_failn
                fi
-       else
-               output "Name for ${label} already set to ${name} "
-               output_okn
-       fi
-}
-
-if [ -s "/etc/config/${packageName}-opkg" ] && ! grep -q 'option name' "/etc/config/${packageName}"; then
-       config_load "$packageName"
+       }
+       config_load "$pkg"
        config_foreach add_name 'file_url'
 fi
 
-# migrate to 1.2.0
-oldval="$(uci_get "$packageName" 'config' 'debug')"
+# ── Migrate to 1.2.0 ────────────────────────────────────────────────
+
+oldval="$(uci -q get "${pkg}.config.debug")"
 if [ -n "$oldval" ]; then
-       uci_set "$packageName" 'config' 'debug_init_script' "$oldval"
-       uci_remove "$packageName" 'config' 'debug'
+       uci set "${pkg}.config.debug_init_script=${oldval}"
+       uci -q delete "${pkg}.config.debug"
 fi
-oldval="$(uci_get "$packageName" 'config' 'proc_debug')"
+
+oldval="$(uci -q get "${pkg}.config.proc_debug")"
 if [ -n "$oldval" ]; then
-       uci_set "$packageName" 'config' 'debug_performance' "$oldval"
-       uci_remove "$packageName" 'config' 'proc_debug'
+       uci set "${pkg}.config.debug_performance=${oldval}"
+       uci -q delete "${pkg}.config.proc_debug"
 fi
 
-# migrate sanity_check to dnsmasq_sanity_check
-if [ -z "$(uci_get "$packageName" 'config' 'dnsmasq_sanity_check')" ] && [ -n "$(uci_get "$packageName" 'config' 'sanity_check')" ]; then
-       oldval="$(uci_get "$packageName" 'config' 'sanity_check')"
-       uci_set "$packageName" 'config' 'dnsmasq_sanity_check' "$oldval"
-       uci_remove "$packageName" 'config' 'sanity_check'
+# ── Migrate sanity_check → dnsmasq_sanity_check ─────────────────────
+
+if [ -z "$(uci -q get "${pkg}.config.dnsmasq_sanity_check")" ] \
+       && [ -n "$(uci -q get "${pkg}.config.sanity_check")" ]; then
+       oldval="$(uci -q get "${pkg}.config.sanity_check")"
+       uci set "${pkg}.config.dnsmasq_sanity_check=${oldval}"
+       uci -q delete "${pkg}.config.sanity_check"
 fi
 
-uci_changes "$packageName" && uci_commit "$packageName"
+# ── Commit if anything changed ───────────────────────────────────────
+
+[ -n "$(uci -q changes "$pkg" 2>/dev/null)" ] && uci commit "$pkg"
 
 exit 0
diff --git a/net/adblock-fast/files/lib/adblock-fast/adblock-fast.uc b/net/adblock-fast/files/lib/adblock-fast/adblock-fast.uc
new file mode 100644 (file)
index 0000000..2967c0e
--- /dev/null
@@ -0,0 +1,2849 @@
+'use strict';
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Copyright 2023-2026 MOSSDeF, Stan Grishin (stangri@melmac.ca).
+//
+// Main ucode module for adblock-fast.
+// All business logic lives here; the init script is a thin procd wrapper.
+
+import { readfile, writefile, popen, stat, unlink, rename, open, glob, mkdir, mkstemp, symlink, chmod, chown, realpath, lsdir, access, dirname } from 'fs';
+import { cursor } from 'uci';
+import { connect } from 'ubus';
+
+// ── Constants ───────────────────────────────────────────────────────
+
+const pkg = {
+       name: 'adblock-fast',
+       version: 'dev-test',
+       compat: '13',
+       memory_threshold: 33554432,
+       config_file: '/etc/config/adblock-fast',
+       dnsmasq_file: '/var/run/adblock-fast/adblock-fast.dnsmasq',
+       run_file: '/dev/shm/adblock-fast',
+       triggers: {
+               reload: 'parallel_downloads debug download_timeout allowed_domain blocked_domain allowed_url blocked_url dns config_update_enabled config_update_url dnsmasq_config_file_url curl_additional_param curl_max_file_size curl_retry',
+               restart: 'compressed_cache compressed_cache_dir force_dns led force_dns_port',
+       },
+};
+pkg.service_name = pkg.name + ' ' + pkg.version;
+
+const dns_modes = {
+       'dnsmasq.addnhosts': {
+               file: '/var/run/' + pkg.name + '/dnsmasq.addnhosts',
+               cache: '/var/run/' + pkg.name + '/dnsmasq.addnhosts.cache',
+               gzip: pkg.name + '.dnsmasq.addnhosts.gz',
+               format_filter: 's|^|127.0.0.1 |;s|$||',
+               format_filter_ipv6: 's|^|:: |;s|$||',
+               parse_filter: 's|^127.0.0.1 ||;s|^:: ||;',
+               grep_pattern_ipv4: 's|^|^127\\.0\\.0\\.1 |',
+               grep_pattern_ipv6: 's|^|^:: |',
+       },
+       'dnsmasq.conf': {
+               file: pkg.dnsmasq_file,
+               cache: '/var/run/' + pkg.name + '/dnsmasq.conf.cache',
+               gzip: pkg.name + '.dnsmasq.conf.gz',
+               format_filter: 's|^|local=/|;s|$|/|',
+               parse_filter: 's|local=/||;s|/$||;',
+               grep_pattern: 's|^|^local=/|;s|$|/$|',
+       },
+       'dnsmasq.ipset': {
+               file: pkg.dnsmasq_file,
+               cache: '/var/run/' + pkg.name + '/dnsmasq.ipset.cache',
+               gzip: pkg.name + '.dnsmasq.ipset.gz',
+               format_filter: 's|^|ipset=/|;s|$|/adb|',
+               parse_filter: 's|ipset=/||;s|/adb$||;',
+               grep_pattern: 's|^|^ipset=/|;s|$|/adb$|',
+       },
+       'dnsmasq.nftset': {
+               file: pkg.dnsmasq_file,
+               cache: '/var/run/' + pkg.name + '/dnsmasq.nftset.cache',
+               gzip: pkg.name + '.dnsmasq.nftset.gz',
+               format_filter: 's|^|nftset=/|;s|$|/4#inet#fw4#adb4|',
+               format_filter_ipv6: 's|^|nftset=/|;s|$|/4#inet#fw4#adb4,6#inet#fw4#adb6|',
+               parse_filter: 's|nftset=/||;s|/4#.*$||;',
+               grep_pattern: 's|^|^nftset=/|;s|$|/4#.*$|',
+       },
+       'dnsmasq.servers': {
+               file: '/var/run/' + pkg.name + '/dnsmasq.servers',
+               cache: '/var/run/' + pkg.name + '/dnsmasq.servers.cache',
+               gzip: pkg.name + '.dnsmasq.servers.gz',
+               format_filter: 's|^|server=/|;s|$|/|',
+               parse_filter: 's|server=/||;s|/.*$||;',
+               grep_pattern: 's|^|^server=/|;s|$|/$|',
+               allow_filter: 's|(.*)|server=/\\1/#|',
+               blocked_count_filter: '\\|/#|d',
+       },
+       'smartdns.domainset': {
+               file: '/var/run/' + pkg.name + '/smartdns.domainset',
+               cache: '/var/run/' + pkg.name + '/smartdns.domainset.cache',
+               gzip: pkg.name + '.smartdns.domainset.gz',
+               config: '/var/run/' + pkg.name + '/smartdns.domainset.conf',
+               format_filter: '',
+               parse_filter: '',
+       },
+       'smartdns.ipset': {
+               file: '/var/run/' + pkg.name + '/smartdns.ipset',
+               cache: '/var/run/' + pkg.name + '/smartdns.ipset.cache',
+               gzip: pkg.name + '.smartdns.ipset.gz',
+               config: '/var/run/' + pkg.name + '/smartdns.ipset.conf',
+               format_filter: '',
+               parse_filter: '',
+       },
+       'smartdns.nftset': {
+               file: '/var/run/' + pkg.name + '/smartdns.nftset',
+               cache: '/var/run/' + pkg.name + '/smartdns.nftset.cache',
+               gzip: pkg.name + '.smartdns.nftset.gz',
+               config: '/var/run/' + pkg.name + '/smartdns.nftset.conf',
+               format_filter: '',
+               parse_filter: '',
+       },
+       'unbound.adb_list': {
+               file: '/var/lib/unbound/adb_list.' + pkg.name,
+               cache: '/var/run/' + pkg.name + '/unbound.cache',
+               gzip: pkg.name + '.unbound.gz',
+               format_filter: 's|^|local-zone: "|;s|$|." always_nxdomain|',
+               parse_filter: 's|^local-zone: "||;s|." always_nxdomain$||;',
+       },
+};
+
+const tmp = {
+       allowed: '/var/' + pkg.name + '.allowed.tmp',
+       a: '/var/' + pkg.name + '.a.tmp',
+       b: '/var/' + pkg.name + '.b.tmp',
+       sed: '/var/' + pkg.name + '.sed.tmp',
+};
+
+const list_formats = {
+       adblockplus: {
+               first_line: '[Adblock Plus]',
+               detect: "'^||'",
+               filter: "/^#/d;/^!/d;s/[[:space:]]*#.*$//;s/^||//;s/\\^$//;s/[[:space:]]*$//;s/[[:cntrl:]]$//;/[[:space:]]/d;/[`~!@#\\$%\\^&\\*()=+;:\"',<>?/\\|[{}]/d;/]/d;/\\./!d;/^$/d;/[^[:alnum:]_.-]/d;",
+       },
+       dnsmasq: {
+               detect: "'^server='",
+               filter: "\\|^server=/[[:alnum:]_.-].*/|!d;s|server=/||;s|/.*$||",
+       },
+       dnsmasq2: {
+               detect: "'^local='",
+               filter: "\\|^local=/[[:alnum:]_.-].*/|!d;s|local=/||;s|/.*$||",
+       },
+       dnsmasq3: {
+               detect: "'^address='",
+               filter: "\\|^address=/[[:alnum:]_.-].*/|!d;s|address=/||;s|/.*$||",
+       },
+       hosts: {
+               detect: "-e '^0\\.0\\.0\\.0\\s' -e '^127\\.0\\.0\\.1\\s'",
+               filter: "/localhost/d;/^#/d;/^[^0-9]/d;s/^0\\.0\\.0\\.0.//;s/^127\\.0\\.0\\.1.//;s/[[:space:]]*#.*$//;s/[[:cntrl:]]$//;s/[[:space:]]//g;/[`~!@#\\$%\\^&\\*()=+;:\"',<>?/\\|[{}]/d;/]/d;/\\./!d;/^$/d;/[^[:alnum:]_.-]/d;",
+       },
+       domains: {
+               filter: "/^#/d;s/[[:space:]]*#.*//;s/[[:space:]]*$//;s/[[:cntrl:]]$//;/^[[:space:]]*$/d;/[[:space:]]/d;/^-/d;/^\\./d;/\\.\\./d;/-$/d;/\\.$/d;/^[0-9.]*$/d;/^[^[:alnum:]]/d;/[`~!@#\\$%\\^&\\*()=+;:\"',<>?/\\|{}]/d;/\\./!d",
+       },
+};
+
+const sym = {
+       dot:  ['.', '[w]'],
+       ok:   ['\033[0;32m✓\033[0m', '\033[0;32m[✓]\033[0m'],
+       fail: ['\033[0;31m✗\033[0m', '\033[0;31m[✗]\033[0m'],
+       warn: ['\033[0;33m✔\033[0m', '\033[0;33m[✔]\033[0m'],
+       ERR:  '\033[0;31m[ERROR]\033[0m',
+       WARN: '\033[0;33m[WARN]\033[0m',
+};
+
+const canary = {
+       mozilla: 'use-application-dns.net',
+       icloud: 'mask.icloud.com mask-h2.icloud.com',
+};
+
+// ── Mutable Module State ────────────────────────────────────────────
+
+let state = {
+       script_name: pkg.name,
+       is_tty: false,
+       output_queue: '',
+       fw4_restart: false,
+};
+
+// ── Environment (platform capabilities, cached detection) ───────────
+
+let env = {
+       // Platform capabilities (set by env.detect())
+       dnsmasq_installed: false,
+       dnsmasq_features: '',
+       smartdns_installed: false,
+       unbound_installed: false,
+       ipset_supported: false,
+       nft_installed: false,
+       awk_cmd: 'awk',
+
+       // Resolver service info (cached)
+       dnsmasq_ubus: null,
+
+       // Downloader (set lazily by env.get_downloader())
+       _dl_cache: null,
+
+       // Guard flags
+       _detected: false,
+       _config_loaded: false,
+       _loaded: false,
+};
+
+let dns_output = {
+       allow_filter: '',
+       blocked_count_filter: '',
+       filter: '',
+       filter_ipv6: '',
+       file: '',
+       gzip: '',
+       cache: '',
+       config: '',
+       parse_filter: '',
+};
+
+// Config values loaded by env.load_config()
+let cfg = {};
+
+// ── Shell / System Helpers ──────────────────────────────────────────
+
+function shell_quote(s) {
+       return "'" + replace('' + s, "'", "'\\''") + "'";
+}
+
+function cmd_output(c) {
+       let p = popen(c, 'r');
+       if (!p) return '';
+       let data = p.read('all') || '';
+       p.close();
+       return trim(data);
+}
+
+function cmd_rc(c) {
+       return system(c + ' >/dev/null 2>&1');
+}
+
+function ensure_trailing_newline(file) {
+       let fh = open(file, 'r+');
+       if (!fh) return;
+       if (fh.seek(-1, 2) && fh.read(1) != '\n')
+               fh.write('\n');
+       fh.close();
+}
+
+function mkdir_p(path) {
+       if (!path || stat(path)?.type == 'directory') return true;
+       let parent = dirname(path);
+       if (parent && parent != path) mkdir_p(parent);
+       return mkdir(path) != null;
+}
+
+function is_present(cmd) {
+       if (index(cmd, '/') >= 0)
+               return access(cmd, 'x') == true;
+       for (let dir in ['/usr/sbin', '/usr/bin', '/sbin', '/bin'])
+               if (access(dir + '/' + cmd, 'x') == true) return true;
+       return false;
+}
+
+function is_integer(v) {
+       if (v == null || v == '') return false;
+       if (!match('' + v, /^[0-9]+$/)) return false;
+       let n = int(v);
+       return n >= 1 && n <= 65535;
+}
+
+function is_https_url(url) {
+       return substr('' + url, 0, 8) == 'https://';
+}
+
+function sanitize_domain(d) {
+       d = replace('' + d, /^[a-z]+:\/\//, '');
+       d = replace(d, /\/.*$/, '');
+       d = replace(d, /:.*$/, '');
+       return d;
+}
+
+function sanitize_dir(d) {
+       let r = realpath(d);
+       if (r && stat(r)?.type == 'directory') return r;
+       return null;
+}
+
+function str_contains_word(haystack, needle) {
+       if (!haystack || !needle) return false;
+       return index(split('' + haystack, /\s+/), needle) >= 0;
+}
+
+// ── Environment Detection ───────────────────────────────────────────
+
+env.detect = function() {
+       if (env._detected) return;
+       env.dnsmasq_installed = is_present('dnsmasq');
+       env.smartdns_installed = is_present('smartdns');
+       env.unbound_installed = is_present('unbound');
+       env.nft_installed = is_present('nft');
+       env.ipset_supported = is_present('ipset') && cmd_rc('/usr/sbin/ipset help hash:net') == 0;
+       if (is_present('gawk')) env.awk_cmd = 'gawk';
+       if (env.dnsmasq_installed && !env.dnsmasq_features) {
+               let raw = cmd_output('dnsmasq --version');
+               let m = match(raw, /Compile time options:(.+)/);
+               env.dnsmasq_features = (m ? m[1] : '') + ' ';
+       }
+       env._detected = true;
+};
+
+env.get_downloader = function() {
+       if (env._dl_cache) return env._dl_cache;
+       let command, flag, ssl_supported;
+       if (is_present('curl')) {
+               command = 'curl -f --silent --insecure';
+               if (cfg.curl_additional_param) command += ' ' + cfg.curl_additional_param;
+               if (cfg.curl_max_file_size) command += ' --max-filesize ' + cfg.curl_max_file_size;
+               if (cfg.curl_retry) command += ' --retry ' + cfg.curl_retry;
+               if (cfg.download_timeout) command += ' --connect-timeout ' + cfg.download_timeout;
+               flag = '-o';
+       } else if (is_present('/usr/libexec/wget-ssl')) {
+               command = '/usr/libexec/wget-ssl --no-check-certificate -q';
+               if (cfg.download_timeout) command += ' --timeout ' + cfg.download_timeout;
+               flag = '-O';
+       } else if (is_present('wget') && cmd_rc("wget --version 2>/dev/null | grep -q '+https'") == 0) {
+               command = 'wget --no-check-certificate -q';
+               if (cfg.download_timeout) command += ' --timeout ' + cfg.download_timeout;
+               flag = '-O';
+       } else {
+               command = 'uclient-fetch --no-check-certificate -q';
+               if (cfg.download_timeout) command += ' --timeout ' + cfg.download_timeout;
+               flag = '-O';
+       }
+       ssl_supported = cmd_rc("curl --version 2>/dev/null | grep -q 'Protocols: .*https.*'") == 0 ||
+               cmd_rc("wget --version 2>/dev/null | grep -q '+ssl'") == 0;
+       env._dl_cache = { command, flag, ssl_supported };
+       return env._dl_cache;
+};
+
+// ── Shell Command Wrappers ──────────────────────────────────────────
+
+function sed_filter(expr, input, output) {
+       return system(sprintf('sed %s %s > %s',
+               shell_quote(expr), shell_quote(input), shell_quote(output))) == 0;
+}
+
+function sed_inplace(expr, file) {
+       return system(sprintf('sed -i %s %s',
+               shell_quote(expr), shell_quote(file))) == 0;
+}
+
+function sed_script(script, input, output) {
+       return system(sprintf('sed -E -f %s %s > %s',
+               shell_quote(script), shell_quote(input), shell_quote(output))) == 0;
+}
+
+function sort_file(input, output, unique) {
+       return system(sprintf('sort %s%s > %s',
+               unique ? '-u ' : '', shell_quote(input), shell_quote(output))) == 0;
+}
+
+function gzip_test(file) {
+       return system(sprintf('gzip -t -c %s >/dev/null 2>/dev/null',
+               shell_quote(file))) == 0;
+}
+
+function gzip_compress(input, output) {
+       return system(sprintf('gzip < %s > %s',
+               shell_quote(input), shell_quote(output))) == 0;
+}
+
+function gzip_decompress(input, output) {
+       return system(sprintf('gzip -dc < %s > %s',
+               shell_quote(input), shell_quote(output))) == 0;
+}
+
+function grep_test(pattern, file, flags) {
+       return cmd_rc(sprintf('grep %s %s %s',
+               flags || '-q', shell_quote(pattern), shell_quote(file))) == 0;
+}
+
+function grep_count(pattern, file, flags) {
+       return int(trim(cmd_output(sprintf('grep %s %s %s',
+               flags || '-c', shell_quote(pattern), shell_quote(file)))) || '0');
+}
+
+function grep_output(pattern, file, flags) {
+       return cmd_output(sprintf('grep %s %s %s',
+               flags || '', shell_quote(pattern), shell_quote(file)));
+}
+
+function grep_exclude_file(patfile, input, output) {
+       return system(sprintf('grep -vFf %s %s > %s 2>/dev/null',
+               shell_quote(patfile), shell_quote(input), shell_quote(output))) == 0;
+}
+
+function count_lines(file, filter_expr) {
+       if (filter_expr)
+               return int(trim(cmd_output(sprintf('sed %s %s | wc -l',
+                       shell_quote(filter_expr), shell_quote(file)))) || '0');
+       return int(trim(cmd_output('wc -l < ' + shell_quote(file))) || '0');
+}
+
+function awk_reverse_labels(input, output) {
+       return system(sprintf("%s -F '.' '{for(i=NF;i>0;i--) printf \"%%s%%s\", $i, (i>1?\".\":\"\\n\")}' %s > %s",
+               env.awk_cmd, shell_quote(input), shell_quote(output))) == 0;
+}
+
+function awk_dedup_subdomains(input, output) {
+       return system(sprintf("%s 'NR==1{prev=$0;print;next}{len=length(prev);if(substr($0,1,len)==prev && substr($0,len+1,1)==\".\") next;print;prev=$0}' %s > %s",
+               env.awk_cmd, shell_quote(input), shell_quote(output))) == 0;
+}
+
+function download(url, dest) {
+       let dlt = env.get_downloader();
+       return system(sprintf('%s %s %s %s 2>/dev/null',
+               dlt.command, shell_quote(url), dlt.flag, shell_quote(dest))) == 0;
+}
+
+function service_restart(name) {
+       return system(sprintf('/etc/init.d/%s restart >/dev/null 2>&1', name)) == 0;
+}
+
+function service_enabled(name) {
+       return system(sprintf('/etc/init.d/%s enabled >/dev/null 2>&1', name)) == 0;
+}
+
+// ── Memory / System Info ────────────────────────────────────────────
+
+function get_mem_available() {
+       let conn = connect();
+       if (!conn) return 0;
+       let info = conn.call('system', 'info');
+       conn.disconnect();
+       if (!info) return 0;
+       let ram = info?.memory?.available || 0;
+       let swap = info?.swap?.free || 0;
+       return ram + swap;
+}
+
+function get_mem_total() {
+       let conn = connect();
+       if (!conn) return 0;
+       let info = conn.call('system', 'info');
+       conn.disconnect();
+       if (!info) return 0;
+       let ram = info?.memory?.total || 0;
+       let swap = info?.swap?.total || 0;
+       return ram + swap;
+}
+
+function led_on(l) {
+       if (l && stat(l + '/trigger'))
+               writefile(l + '/trigger', 'default-on\n');
+}
+
+function led_off(l) {
+       if (l && stat(l + '/trigger'))
+               writefile(l + '/trigger', 'none\n');
+}
+
+function logger(msg) {
+       system('/usr/bin/logger -t ' + shell_quote(state.script_name) + ' ' + shell_quote(msg));
+}
+
+function logger_debug(msg) {
+       if (cfg.debug_performance)
+               system('/usr/bin/logger -t ' + shell_quote(state.script_name) + ' ' + shell_quote(msg));
+}
+
+// ── Output Management ───────────────────────────────────────────────
+
+let _write = function(level, ...args) {
+       if (!cfg.verbosity)
+               cfg.verbosity = int(uci(pkg.name).get(pkg.name, 'config', 'verbosity') || '1');
+       let msg = join('', args);
+       if (level != null && (cfg.verbosity & level) == 0) return;
+
+       // Print to stderr (terminal)
+       if (state.is_tty)
+               warn(replace(msg, /\\n/g, '\n'));
+
+       // Queue for logger: flush on newline
+       if (index(msg, '\\n') >= 0 || index(msg, '\n') >= 0) {
+               msg = state.output_queue + msg;
+               state.output_queue = '';
+               let clean = replace(msg, /\x1b\[[0-9;]*m/g, '');
+               clean = replace(clean, /\\n/g, '\n');
+               clean = trim(clean);
+               if (clean != '')
+                       system('/usr/bin/logger -t ' + shell_quote(state.script_name) + ' ' + shell_quote(clean));
+       } else {
+               state.output_queue += msg;
+       }
+};
+
+let output = {
+       _write:  _write,
+       info:    function(...args) { _write(1, ...args); },
+       verbose: function(...args) { _write(2, ...args); },
+       print:   function(...args) { _write(null, ...args); },
+       ok:      function() { _write(1, sym.ok[0]); _write(2, sym.ok[1] + '\\n'); },
+       okn:     function() { _write(1, sym.ok[0] + '\\n'); _write(2, sym.ok[1] + '\\n'); },
+       fail:    function() { _write(1, sym.fail[0]); _write(2, sym.fail[1] + '\\n'); },
+       failn:   function() { _write(1, sym.fail[0] + '\\n'); _write(2, sym.fail[1] + '\\n'); },
+       warn:    function() { _write(1, sym.warn[0]); _write(2, sym.warn[1] + '\\n'); },
+       warnn:   function() { _write(1, sym.warn[0] + '\\n'); _write(2, sym.warn[1] + '\\n'); },
+       dot:     function() { _write(1, sym.dot[0]); _write(2, sym.dot[1]); },
+       dns:     function(msg) {
+               if (!cfg.dns) return;
+               let d = '' + cfg.dns;
+               if (index(d, 'dnsmasq.') == 0) _write(2, '[DNSM] ' + msg);
+               else if (index(d, 'smartdns.') == 0) _write(2, '[SMRT] ' + msg);
+               else if (index(d, 'unbound.') == 0) _write(2, '[UNBD] ' + msg);
+       },
+       error:   function(msg) { _write(null, sym.ERR + ' ' + msg + '!\\n'); },
+       warning: function(msg) { _write(null, sym.WARN + ' ' + msg + '!\\n'); },
+};
+
+// ── UCI Cursor ──────────────────────────────────────────────────────
+
+let _cursor = null;
+let _cursor_loaded = {};
+
+function uci(config, reload) {
+       if (!_cursor) _cursor = cursor();
+       if (!_cursor_loaded[config] || reload) {
+               _cursor.load(config);
+               _cursor_loaded[config] = true;
+       }
+       return _cursor;
+}
+
+function uci_has_changes(config) {
+       return length(uci(config).changes(config) || []) > 0;
+}
+
+function uci_list_add_if_new(config, section, option, value) {
+       if (!config || !section || !option || !value) return false;
+       let ctx = uci(config);
+       let current = ctx.get(config, section, option);
+       if (type(current) == 'array' && index(current, value) >= 0) return true;
+       if (current == value) return true;
+       ctx.list_append(config, section, option, value);
+       ctx.save(config);
+       return true;
+}
+
+// ── Status Data ─────────────────────────────────────────────────────
+
+let status_data = {
+       status: '',
+       message: '',
+       stats: '',
+       errors: [],
+       warnings: [],
+};
+
+function _load_status_from_ubus() {
+       let conn = connect();
+       if (!conn) return;
+       let svc = conn.call('service', 'list', { name: pkg.name });
+       conn.disconnect();
+       let data = svc?.[pkg.name]?.data;
+       if (!data) return;
+       status_data.status = data.status || '';
+       status_data.message = data.message || '';
+       status_data.stats = data.stats || '';
+       status_data.errors = data.errors || [];
+       status_data.warnings = data.warnings || [];
+}
+
+function _update_ubus_status() {
+       let conn = connect();
+       if (!conn) return;
+       let svc = conn.call('service', 'list', { name: pkg.name });
+       let data = svc?.[pkg.name]?.data;
+       if (!data) { conn.disconnect(); return; }
+       data.status = status_data.status;
+       data.message = status_data.message;
+       data.stats = status_data.stats;
+       data.errors = [];
+       for (let e in status_data.errors)
+               push(data.errors, { code: e.code, info: e.info });
+       data.warnings = [];
+       for (let e in status_data.warnings)
+               push(data.warnings, { code: e.code, info: e.info });
+       conn.call('service', 'set_data', { name: pkg.name, data: data });
+       conn.disconnect();
+}
+
+function _status_reset() {
+       status_data.status = '';
+       status_data.message = '';
+       status_data.stats = '';
+       status_data.errors = [];
+       status_data.warnings = [];
+}
+
+// ── get_text ────────────────────────────────────────────────────────
+
+function get_text(r, ...args) {
+       let a = args[0] || '';
+       switch (r) {
+       case 'errorConfigValidationFail': return sprintf("The %s config validation failed", pkg.name);
+       case 'errorServiceDisabled': return sprintf("The %s is currently disabled", pkg.name);
+       case 'errorNoDnsmasqIpset': return sprintf("The dnsmasq ipset support is enabled in %s, but dnsmasq is either not installed or installed dnsmasq does not support ipset", pkg.name);
+       case 'errorNoIpset': return sprintf("The dnsmasq ipset support is enabled in %s, but ipset is either not installed or installed ipset does not support 'hash:net' type", pkg.name);
+       case 'errorNoDnsmasqNftset': return sprintf("The dnsmasq nft set support is enabled in %s, but dnsmasq is either not installed or installed dnsmasq does not support nft set", pkg.name);
+       case 'errorNoNft': return sprintf("The dnsmasq nft sets support is enabled in %s, but nft is not installed", pkg.name);
+       case 'errorNoWanGateway': return sprintf("The %s failed to discover WAN gateway", pkg.service_name);
+       case 'errorOutputDirCreate': return sprintf("Failed to create directory for %s file", a);
+       case 'errorOutputFileCreate': return sprintf("Failed to create %s file", a);
+       case 'errorFailDNSReload': return "Failed to restart/reload DNS resolver";
+       case 'errorSharedMemory': return "Failed to access shared memory";
+       case 'errorSorting': return "Failed to sort data file";
+       case 'errorOptimization': return "Failed to optimize data file";
+       case 'errorAllowListProcessing': return "Failed to process allow-list";
+       case 'errorDataFileFormatting': return "Failed to format data file";
+       case 'errorCopyingDataFile': return sprintf("Failed to copy data file to '%s'", a);
+       case 'errorMovingDataFile': return sprintf("Failed to move data file to '%s'", a);
+       case 'errorCreatingCompressedCache': return "Failed to create compressed cache";
+       case 'errorRemovingTempFiles': return "Failed to remove temporary files";
+       case 'errorRestoreCompressedCache': return "Failed to unpack compressed cache";
+       case 'errorRestoreCache': return sprintf("Failed to move '%s' to '%s'", dns_output.cache, dns_output.file);
+       case 'errorOhSnap': return "Failed to create block-list or restart DNS resolver";
+       case 'errorStopping': return sprintf("Failed to stop %s", pkg.service_name);
+       case 'errorDNSReload': return "Failed to reload/restart DNS resolver";
+       case 'errorDownloadingConfigUpdate': return "Failed to download Config Update file";
+       case 'errorDownloadingList': return sprintf("Failed to download %s", a);
+       case 'errorParsingConfigUpdate': return "Failed to parse Config Update file";
+       case 'errorParsingList': return "Failed to parse";
+       case 'errorNoSSLSupport': return "No HTTPS/SSL support on device";
+       case 'errorCreatingDirectory': return "Failed to create output/cache/gzip file directory";
+       case 'errorDetectingFileType': return "Failed to detect format";
+       case 'errorNothingToDo': return "No blocked list URLs nor blocked-domains enabled";
+       case 'errorTooLittleRam': return sprintf("Free ram (%s) is not enough to process all enabled block-lists", a);
+       case 'errorCreatingBackupFile': return sprintf("Failed to create backup file %s", a);
+       case 'errorDeletingDataFile': return sprintf("Failed to delete data file %s", a);
+       case 'errorRestoringBackupFile': return sprintf("Failed to restore backup file %s", a);
+       case 'errorNoOutputFile': return sprintf("Failed to create final block-list %s", a);
+       case 'errorNoHeartbeat': return "Heartbeat domain is not accessible after resolver restart";
+       case 'statusNoInstall': return sprintf("The %s is not installed or not found", pkg.service_name);
+       case 'statusStopped': return "stopped";
+       case 'statusStarting': return "starting";
+       case 'statusRestarting': return "restarting";
+       case 'statusForceReloading': return "force-reloading";
+       case 'statusDownloading': return "downloading";
+       case 'statusProcessing': return "processing";
+       case 'statusFail': return "failed to start";
+       case 'statusSuccess': return "success";
+       case 'statusTriggerBootWait': return "waiting for trigger (on_boot)";
+       case 'statusTriggerStartWait': return "waiting for trigger (on_start)";
+       case 'warningExternalDnsmasqConfig': return "Use of external dnsmasq config file detected, please set 'dns' option to 'dnsmasq.conf'";
+       case 'warningMissingRecommendedPackages': return "Some recommended packages are missing";
+       case 'warningInvalidCompressedCacheDir': return sprintf("Invalid compressed cache directory '%s'", a);
+       case 'warningFreeRamCheckFail': return "Can't detect free RAM";
+       case 'warningSanityCheckTLD': return sprintf("Sanity check discovered TLDs in %s", a);
+       case 'warningSanityCheckLeadingDot': return sprintf("Sanity check discovered leading dots in %s", a);
+       case 'warningInvalidDomainsRemoved': return sprintf("Removed %s invalid domain entries from block-list (domains starting with -/./numbers or containing invalid patterns)", a);
+       default: return sprintf("Unknown error/warning '%s'", a);
+       }
+}
+
+// ── Resolver Checks (env methods) ───────────────────────────────────
+
+env.check_dnsmasq = function() { env.detect(); return env.dnsmasq_installed; };
+env.check_smartdns = function() { env.detect(); return env.smartdns_installed; };
+env.check_unbound = function() { env.detect(); return env.unbound_installed; };
+env.check_ipset = function() { env.detect(); return env.ipset_supported; };
+env.check_nft = function() { env.detect(); return env.nft_installed; };
+
+env.check_dnsmasq_feature = function(feat) {
+       env.detect();
+       switch (feat) {
+       case 'idn': return index(env.dnsmasq_features, ' IDN ') >= 0;
+       case 'ipset': return index(env.dnsmasq_features, ' ipset ') >= 0;
+       case 'nftset': return index(env.dnsmasq_features, ' nftset ') >= 0;
+       }
+       return false;
+};
+
+env.check_dnsmasq_ipset = function() { return env.check_ipset() && env.check_dnsmasq_feature('ipset'); };
+env.check_dnsmasq_nftset = function() { return env.check_nft() && env.check_dnsmasq_feature('nftset'); };
+
+// ── Port/Firewall Helpers ───────────────────────────────────────────
+
+function is_port_listening(port) {
+       if (!is_integer(port)) return false;
+       let port_hex = sprintf('%04X', int(port));
+       for (let path in ['/proc/net/tcp', '/proc/net/tcp6']) {
+               let lines = split(readfile(path) || '', '\n');
+               for (let i = 1; i < length(lines); i++) {
+                       let fields = split(trim(lines[i]), /\s+/);
+                       if (length(fields) < 4) continue;
+                       if (uc(split(fields[1], ':')[1]) == port_hex && fields[3] == '0A')
+                               return true;
+               }
+       }
+       for (let path in ['/proc/net/udp', '/proc/net/udp6']) {
+               let lines = split(readfile(path) || '', '\n');
+               for (let i = 1; i < length(lines); i++) {
+                       let fields = split(trim(lines[i]), /\s+/);
+                       if (length(fields) < 2) continue;
+                       if (uc(split(fields[1], ':')[1]) == port_hex)
+                               return true;
+               }
+       }
+       return false;
+}
+
+function is_fw4_restart_needed() {
+       if (state.fw4_restart) return true;
+       let d = (uci(pkg.name).get(pkg.name, 'config', 'dns') ?? 'dnsmasq.servers');
+       let fd = (uci(pkg.name).get(pkg.name, 'config', 'force_dns') ?? '1');
+       if (fd == '1') return true;
+       if (d == 'dnsmasq.ipset' || d == 'dnsmasq.nftset' ||
+               d == 'smartdns.ipset' || d == 'smartdns.nftset') return true;
+       return false;
+}
+
+// ── File Size Helpers ───────────────────────────────────────────────
+
+function get_local_filesize(file) {
+       let s = stat(file);
+       return s ? s.size : null;
+}
+
+function get_url_filesize(url) { // ucode-lsp disable
+       if (!url) return null;
+       let size = '';
+       if (is_present('curl')) {
+               size = cmd_output(sprintf("curl --silent --insecure --fail --head --request GET --connect-timeout 2 %s | awk -F': ' '{IGNORECASE=1}/content-length/ {gsub(/\\r/, \"\"); print $2}'", shell_quote(url)));
+       }
+       if (!size && is_present('uclient-fetch')) {
+               size = cmd_output(sprintf("uclient-fetch --spider --timeout 2 %s -O /dev/null 2>&1 | sed -n '/^Download/ s/.*\\(\\([0-9]*\\) bytes\\).*/\\1/p'", shell_quote(url)));
+       }
+       return size ? size : null;
+}
+
+// ── count_blocked_domains ───────────────────────────────────────────
+
+function count_blocked_domains() {
+       if (!dns_output.file || !stat(dns_output.file)) return '0';
+       if (dns_output.blocked_count_filter)
+               return '' + count_lines(dns_output.file, dns_output.blocked_count_filter);
+       return '' + count_lines(dns_output.file);
+}
+
+// ── DNS Output Values ───────────────────────────────────────────────
+
+env.dns_set_output_values = function(d) {
+       let dc = dns_modes[d];
+       if (!dc) return;
+       dns_output.file = dc.file;
+       dns_output.cache = dc.cache;
+       dns_output.gzip = cfg.compressed_cache_dir + '/' + dc.gzip;
+       dns_output.parse_filter = dc.parse_filter;
+       dns_output.config = dc.config || '';
+       dns_output.allow_filter = dc.allow_filter || '';
+       dns_output.blocked_count_filter = dc.blocked_count_filter || '';
+       dns_output.filter_ipv6 = '';
+       if (d == 'dnsmasq.nftset' && cfg.ipv6_enabled && dc.format_filter_ipv6)
+               dns_output.filter = dc.format_filter_ipv6;
+       else
+               dns_output.filter = dc.format_filter;
+       if (d == 'dnsmasq.addnhosts' && cfg.ipv6_enabled && dc.format_filter_ipv6)
+               dns_output.filter_ipv6 = dc.format_filter_ipv6;
+};
+
+// ── adb_file ────────────────────────────────────────────────────────
+
+function adb_file(action) {
+       switch (action) {
+       case 'create':
+       case 'backup':
+               if (stat(dns_output.file)?.size > 0)
+                       return rename(dns_output.file, dns_output.cache) == true;
+               return false;
+       case 'restore':
+       case 'use':
+               if (stat(dns_output.cache)?.size > 0)
+                       return rename(dns_output.cache, dns_output.file) == true;
+               return false;
+       case 'test':
+       case 'test_file':
+               return (stat(dns_output.file)?.size > 0);
+       case 'test_cache':
+               return (stat(dns_output.cache)?.size > 0);
+       case 'test_gzip':
+               return (stat(dns_output.gzip)?.size > 0) && gzip_test(dns_output.gzip);
+       case 'create_gzip':
+               if (!(stat(dns_output.file)?.size > 0)) return false;
+               unlink(dns_output.gzip);
+               // Write temp file in same directory as destination to avoid cross-filesystem rename
+               let gz_tmp = dns_output.gzip + '.tmp';
+               if (gzip_compress(dns_output.file, gz_tmp)) {
+                       if (rename(gz_tmp, dns_output.gzip)) {
+                               return true;
+                       }
+                       unlink(gz_tmp);
+               }
+               return false;
+       case 'expand':
+       case 'unpack':
+       case 'unpack_gzip':
+               if (stat(dns_output.gzip)?.size > 0)
+                       return gzip_decompress(dns_output.gzip, dns_output.cache);
+               return false;
+       case 'remove_cache':
+               unlink(dns_output.cache);
+               return true;
+       case 'remove_gzip':
+               unlink(dns_output.gzip);
+               return true;
+       }
+       return false;
+}
+
+// ── Declarative Config Schema ───────────────────────────────────────
+// Each entry: [type, default] — mirrors the shell load_validate_config() spec.
+
+const config_schema = { // ucode-lsp disable
+       // Booleans
+       allow_non_ascii:         ['bool', false],
+       canary_domains_icloud:   ['bool', false],
+       canary_domains_mozilla:  ['bool', false],
+       compressed_cache:        ['bool', false],
+       config_update_enabled:   ['bool', false],
+       debug_init_script:       ['bool', false],
+       debug_performance:       ['bool', false],
+       dnsmasq_sanity_check:    ['bool', true],
+       dnsmasq_validity_check:  ['bool', false],
+       enabled:                 ['bool', false],
+       force_dns:               ['bool', true],
+       ipv6_enabled:            ['bool', false],
+       parallel_downloads:      ['bool', true],
+       procd_trigger_wan6:      ['bool', false],
+       update_config_sizes:     ['bool', true],
+       // Strings
+       config_update_url:       ['string', 'https://cdn.jsdelivr.net/gh/openwrt/packages/net/adblock-fast/files/adblock-fast.config.update'],
+       curl_additional_param:   ['string'],
+       curl_max_file_size:      ['string'],
+       curl_retry:              ['string', '3'],
+       dns:                     ['string', 'dnsmasq.servers'],
+       dnsmasq_config_file_url: ['string'],
+       download_timeout:        ['string', '20'],
+       heartbeat_sleep_timeout: ['string', '10'],
+       led:                     ['string'],
+       pause_timeout:           ['string', '20'],
+       procd_boot_wan_timeout:  ['string', '60'],
+       // Integers
+       verbosity:               ['int', 2],
+       // Lists
+       allowed_domain:          ['list'],
+       blocked_domain:          ['list'],
+       dnsmasq_instance:        ['list', '*'],
+       force_dns_interface:     ['list', 'lan'],
+       force_dns_port:          ['list', '53 853'],
+       smartdns_instance:       ['list', '*'],
+       // Domain (sanitized, '-' means disabled)
+       heartbeat_domain:        ['domain', 'heartbeat.melmac.ca'],
+       // Directory (validated via realpath)
+       compressed_cache_dir:    ['dir', '/etc'],
+};
+
+// ── parse_options ───────────────────────────────────────────────────
+
+function parse_options(raw, schema) { // ucode-lsp disable
+       let result = {};
+       for (let key in schema) {
+               let spec = schema[key];
+               let v = raw[key];
+               switch (spec[0]) {
+               case 'bool':
+                       result[key] = (v == null) ? spec[1] : (v == '1' || v == 'yes' || v == 'on' || v == 'true');
+                       break;
+               case 'string':
+                       result[key] = (v == null) ? (spec[1] ?? null) : '' + v;
+                       break;
+               case 'int':
+                       result[key] = (v == null) ? (spec[1] ?? 0) : int(v);
+                       break;
+               case 'list':
+                       if (v == null) { result[key] = spec[1] ?? null; }
+                       else { result[key] = replace((type(v) == 'array') ? join(' ', v) : '' + v, /,/g, ' '); }
+                       break;
+               case 'domain':
+                       if (v == null || v == '-') result[key] = spec[1] ?? null;
+                       else result[key] = sanitize_domain('' + v) || spec[1] || null;
+                       break;
+               case 'dir':
+                       let d = sanitize_dir('' + (v ?? spec[1] ?? ''));
+                       result[key] = (d == '/') ? '' : (d || spec[1] || '');
+                       break;
+               }
+       }
+       return result;
+}
+
+// ── env.load_config ─────────────────────────────────────────────────
+
+env.load_config = function() {
+       if (env._config_loaded) return;
+       state.is_tty = system('[ -t 2 ]') == 0 ? true : false;
+       let raw = uci(pkg.name, true).get_all(pkg.name, 'config') || {};
+       cfg = parse_options(raw, config_schema);
+       env.dns_set_output_values(cfg.dns);
+       env._loaded = false;
+       env._detected = false;
+       env._dl_cache = null;
+       env._config_loaded = true;
+};
+
+// ── load_dl_command ─────────────────────────────────────────────────
+
+// Thin wrapper for backward compat; real logic is in env.get_downloader()
+function load_dl_command() { env.get_downloader(); }
+
+
+// ── detect_file_type ────────────────────────────────────────────────
+
+function detect_file_type(file) {
+       let first_line = split(readfile(file) || '', '\n')[0];
+       for (let name in keys(list_formats)) {
+               let fmt = list_formats[name];
+               if (fmt.first_line && first_line == fmt.first_line) return name;
+               if (fmt.detect && cmd_rc("grep -q " + fmt.detect + " " + shell_quote(file)) == 0) return name;
+       }
+       if (list_formats.domains) {
+               let test = cmd_output(sprintf("sed %s %s 2>/dev/null | head -1", shell_quote(list_formats.domains.filter), shell_quote(file)));
+               if (test) return 'domains';
+       }
+       return null;
+}
+
+// ── adb_config_cache ────────────────────────────────────────────────
+
+function adb_config_cache(action, variable) {
+       switch (action) {
+       case 'create':
+       case 'set':
+               writefile(pkg.run_file, readfile(pkg.config_file) || '');
+               return;
+       case 'get':
+               switch (variable) {
+               case 'trigger_fw4':
+                       if (stat(pkg.run_file)?.size > 0) {
+                               if (is_fw4_restart_needed()) return 'true';
+                       }
+                       return '';
+               case 'trigger_service':
+                       if (!(stat(pkg.run_file)?.size > 0)) return 'on_boot';
+                       if ((readfile(pkg.config_file) || '') != (readfile(pkg.run_file) || '')) {
+                               // Config changed — determine if reload or restart
+                               let run_dir = dirname(pkg.run_file);
+                               let cached = cursor(run_dir);
+                               cached.load(pkg.name);
+                               let reload_triggers = split(pkg.triggers.reload, ' ');
+                               for (let t in reload_triggers) {
+                                       if (!t) continue;
+                                       if (t == 'allowed_url' || t == 'blocked_url') continue;
+                                       let val_current = uci(pkg.name).get(pkg.name, 'config', t);
+                                       let val_old = cached.get(pkg.name, 'config', t);
+                                       if ('' + (val_current ?? '') != '' + (val_old ?? '')) return 'download';
+                               }
+                               let restart_triggers = split(pkg.triggers.restart, ' ');
+                               for (let t in restart_triggers) {
+                                       if (!t) continue;
+                                       let val_current = uci(pkg.name).get(pkg.name, 'config', t);
+                                       let val_old = cached.get(pkg.name, 'config', t);
+                                       if ('' + (val_current ?? '') != '' + (val_old ?? '')) return 'restart';
+                               }
+                       }
+                       return '';
+               default: {
+                       let run_dir = dirname(pkg.run_file);
+                       let old_cfg = cursor(run_dir);
+                       old_cfg.load(pkg.name);
+                       return old_cfg.get(pkg.name, 'config', variable) ?? '';
+               }
+               }
+       }
+}
+
+// ── append_url (collect file_url sections) ──────────────────────────
+
+function append_urls() { // ucode-lsp disable
+       cfg.allowed_url = '';
+       cfg.blocked_url = '';
+       uci(pkg.name).foreach(pkg.name, 'file_url', (s) => {
+               if (s.enabled == '0') return;
+               let url = s.url;
+               if (!url) return;
+               if ((s.action || 'block') == 'allow')
+                       cfg.allowed_url = (cfg.allowed_url ? cfg.allowed_url + ' ' : '') + url;
+               else
+                       cfg.blocked_url = (cfg.blocked_url ? cfg.blocked_url + ' ' : '') + url;
+       });
+}
+
+// ── env.load ────────────────────────────────────────────────────────
+
+env.load = function(param, validation_result) {
+       if (env._loaded) return true;
+       env.load_config();
+
+       if (!cfg.enabled) {
+               push(status_data.errors, { code: 'errorServiceDisabled', info: '' });
+               output.error(get_text('errorServiceDisabled'));
+               output.print("Run the following commands before starting service again:\\n");
+               output.print("uci set " + pkg.name + ".config.enabled='1'; uci commit " + pkg.name + ";\\n");
+               return false;
+       }
+
+       if (validation_result && validation_result != '0') {
+               output.info(sym.fail[0] + '\\n');
+               push(status_data.errors, { code: 'errorConfigValidationFail', info: '' });
+               output.error(get_text('errorConfigValidationFail'));
+               output.print("Please check if the '" + pkg.config_file + "' contains correct values for config options.\\n");
+               return false;
+       }
+
+       // ── nested helpers ──────────────────────────────────────────────
+
+       let _check_resolver_environment = function() {
+               // Check resolver presence
+               let dns_family = split(cfg.dns, '.')[0];
+               switch (dns_family) {
+               case 'dnsmasq':
+                       if (!env.check_dnsmasq()) {
+                               if (param != 'quiet') {
+                                       push(status_data.errors, { code: 'errorDNSReload', info: '' });
+                                       output.error("Resolver 'dnsmasq' not found");
+                               }
+                               return false;
+                       }
+                       if (env.check_dnsmasq_feature('idn')) cfg.allow_non_ascii = false;
+                       break;
+               case 'smartdns':
+                       if (!env.check_smartdns()) {
+                               if (param != 'quiet') {
+                                       push(status_data.errors, { code: 'errorDNSReload', info: '' });
+                                       output.error("Resolver 'smartdns' not found");
+                               }
+                               return false;
+                       }
+                       cfg.allow_non_ascii = false;
+                       break;
+               case 'unbound':
+                       if (!env.check_unbound()) {
+                               if (param != 'quiet') {
+                                       push(status_data.errors, { code: 'errorDNSReload', info: '' });
+                                       output.error("Resolver 'unbound' not found");
+                               }
+                               return false;
+                       }
+                       cfg.allow_non_ascii = true;
+                       break;
+               }
+
+               // Check specific cfg.dns mode support
+               switch (cfg.dns) {
+               case 'dnsmasq.ipset':
+                       if (!env.check_dnsmasq_feature('ipset')) {
+                               if (param != 'quiet') push(status_data.errors, { code: 'errorNoDnsmasqIpset', info: '' });
+                               cfg.dns = 'dnsmasq.servers';
+                       }
+                       if (!env.check_ipset()) {
+                               if (param != 'quiet') push(status_data.errors, { code: 'errorNoIpset', info: '' });
+                               cfg.dns = 'dnsmasq.servers';
+                       }
+                       break;
+               case 'dnsmasq.nftset':
+                       if (!env.check_dnsmasq_feature('nftset')) {
+                               if (param != 'quiet') push(status_data.errors, { code: 'errorNoDnsmasqNftset', info: '' });
+                               cfg.dns = 'dnsmasq.servers';
+                       }
+                       if (!env.check_nft()) {
+                               if (param != 'quiet') push(status_data.errors, { code: 'errorNoNft', info: '' });
+                               cfg.dns = 'dnsmasq.servers';
+                       }
+                       break;
+               case 'smartdns.ipset':
+                       if (!env.check_ipset()) {
+                               if (param != 'quiet') push(status_data.errors, { code: 'errorNoIpset', info: '' });
+                               cfg.dns = 'smartdns.domainset';
+                       }
+                       break;
+               case 'smartdns.nftset':
+                       if (!env.check_nft()) {
+                               if (param != 'quiet') push(status_data.errors, { code: 'errorNoNft', info: '' });
+                               cfg.dns = 'smartdns.domainset';
+                       }
+                       break;
+               }
+
+               if (cfg.dnsmasq_config_file_url) {
+                       cfg.update_config_sizes = false;
+                       if (cfg.dns != 'dnsmasq.conf') {
+                               cfg.dns = 'dnsmasq.conf';
+                               if (param != 'quiet')
+                                       push(status_data.warnings, { code: 'warningExternalDnsmasqConfig', info: '' });
+                       }
+               }
+
+               // Re-sync dns_output after any cfg.dns fallback
+               env.dns_set_output_values(cfg.dns);
+
+               // Clean up files for non-active cfg.dns modes
+               for (let mode in dns_modes) {
+                       if (mode == cfg.dns) continue;
+                       let dc = dns_modes[mode];
+                       unlink(dc.cache);
+                       unlink(cfg.compressed_cache_dir + '/' + dc.gzip);
+                       if (dc.file != pkg.dnsmasq_file) unlink(dc.file);
+                       if (dc.config) unlink(dc.config);
+               }
+
+               return true;
+       };
+
+       let _setup_directories = function() {
+               let dirs = [pkg.run_file, dns_output.file, dns_output.cache, dns_output.gzip, dns_output.config];
+               for (let f in dirs) {
+                       if (!f) continue;
+                       let dir = dirname(f);
+                       if (!mkdir_p(dir)) {
+                               if (param != 'quiet')
+                                       push(status_data.errors, { code: 'errorOutputDirCreate', info: f });
+                       }
+               }
+       };
+
+       let _check_recommended_packages = function() {
+               let bins = {
+                       gawk:  ['gawk', 'gawk'],
+                       grep:  ['/usr/libexec/grep-gnu', 'grep'],
+                       sed:   ['/usr/libexec/sed-gnu', 'sed'],
+                       sort:  ['/usr/libexec/sort-coreutils', 'coreutils-sort'],
+               };
+               let missing = [];
+               for (let key in bins) {
+                       if (!is_present(bins[key][0])) {
+                               push(status_data.warnings, { code: 'warningMissingRecommendedPackages', info: bins[key][1] });
+                               push(missing, bins[key][1]);
+                       }
+               }
+               if (length(missing) && param != 'quiet') {
+                       output.warning(get_text('warningMissingRecommendedPackages') + ', install them by running:');
+                       output.print('opkg update; opkg --force-overwrite install ' + join(' ', missing) + ';');
+               }
+       };
+
+       let _check_wan_gateway = function() {
+               let ub = connect();
+               if (!ub) return false;
+               let dump = ub.call('network.interface', 'dump');
+               ub.disconnect();
+               if (!dump?.interface) return false;
+               for (let iface in dump.interface) {
+                       for (let r in (iface.route || []))
+                               if (r.target == '0.0.0.0') return true;
+               }
+               return false;
+       };
+
+       // ── param-driven branches ───────────────────────────────────────
+
+       switch (param) {
+       case 'on_boot':
+               // Minimal: just config + dns_output (for cache restore)
+               break;
+
+       case 'on_start':
+       case 'download':
+       case 'reload':
+       case 'restart':
+       default:
+               // Full pipeline
+               env.detect();
+               if (!_check_resolver_environment()) return false;
+               _setup_directories();
+               _check_recommended_packages();
+               if (!_check_wan_gateway()) {
+                       push(status_data.errors, { code: 'errorNoWanGateway', info: '' });
+                       output.error(get_text('errorNoWanGateway'));
+                       return false;
+               }
+               append_urls();
+               if (cfg.led) cfg.led = '/sys/class/leds/' + cfg.led;
+               break;
+
+       case 'quiet':
+               env.detect();
+               _check_resolver_environment();
+               break;
+
+       case 'rpcd':
+               env.detect();
+               break;
+       }
+
+       env._loaded = true;
+       return true;
+};
+
+// ── resolver ────────────────────────────────────────────────────────
+
+function _dnsmasq_instance_get_confdir(inst) {
+       // Get the UCI section name for this instance
+       let uci_name = uci('dhcp').get('dhcp', inst, '.name') || inst;
+       // Cache dnsmasq service info via ubus
+       if (!env.dnsmasq_ubus) {
+               let ub = connect();
+               if (ub) {
+                       env.dnsmasq_ubus = ub.call('service', 'list', { name: 'dnsmasq' });
+                       ub.disconnect();
+               }
+       }
+       // Extract the -C config file from the dnsmasq instance command line
+       let cfg_file = null;
+       let cmd_arr = env.dnsmasq_ubus?.dnsmasq?.instances?.[uci_name]?.command;
+       if (type(cmd_arr) == 'array') {
+               for (let i = 0; i < length(cmd_arr); i++)
+                       if (cmd_arr[i] == '-C' && i + 1 < length(cmd_arr)) { cfg_file = cmd_arr[i + 1]; break; }
+       }
+       if (!cfg_file) return null;
+       // Parse conf-dir from the dnsmasq config file
+       let content = readfile(cfg_file) || '';
+       if (!content) return null;
+       for (let line in split(content, '\n')) {
+               let m = match(line, /^conf-dir=(.+)$/);
+               if (m) return m[1];
+       }
+       return null;
+}
+
+function _dnsmasq_instance_config(inst, param) {
+       if (!stat('/etc/config/dhcp')?.size) return;
+       let dhcp = uci('dhcp');
+       if (!dhcp.get('dhcp', inst)) return;
+       let confdir;
+       let addnhostsFile = dns_modes['dnsmasq.addnhosts'].file;
+       let confFile = dns_modes['dnsmasq.conf'].file;
+       let serversFile = dns_modes['dnsmasq.servers'].file;
+       switch (param) {
+       case 'dnsmasq.addnhosts':
+               confdir = _dnsmasq_instance_get_confdir(inst);
+               if (confdir) unlink(confdir + '/' + pkg.name);
+               dhcp.list_remove('dhcp', inst, 'addnmount', confFile);
+               if (dhcp.get('dhcp', inst, 'serversfile') == serversFile)
+                       dhcp.delete('dhcp', inst, 'serversfile');
+               uci_list_add_if_new('dhcp', inst, 'addnhosts', addnhostsFile);
+               break;
+       case 'cleanup':
+       case 'unbound.adb_list':
+               confdir = _dnsmasq_instance_get_confdir(inst);
+               if (confdir) unlink(confdir + '/' + pkg.name);
+               dhcp.list_remove('dhcp', inst, 'addnhosts', addnhostsFile);
+               dhcp.list_remove('dhcp', inst, 'addnmount', confFile);
+               if (dhcp.get('dhcp', inst, 'serversfile') == serversFile)
+                       dhcp.delete('dhcp', inst, 'serversfile');
+               break;
+       case 'dnsmasq.conf':
+       case 'dnsmasq.ipset':
+       case 'dnsmasq.nftset':
+               dhcp.list_remove('dhcp', inst, 'addnhosts', addnhostsFile);
+               if (dhcp.get('dhcp', inst, 'serversfile') == serversFile)
+                       dhcp.delete('dhcp', inst, 'serversfile');
+               uci_list_add_if_new('dhcp', inst, 'addnmount', confFile);
+               confdir = _dnsmasq_instance_get_confdir(inst);
+               if (!confdir) { dhcp.save('dhcp'); return; }
+               unlink(confdir + '/' + pkg.name);
+               symlink(confFile, confdir + '/' + pkg.name);
+               chmod(confdir + '/' + pkg.name, 0660);
+               chown(confdir + '/' + pkg.name, 'root', 'dnsmasq');
+               break;
+       case 'dnsmasq.servers':
+               dhcp.list_remove('dhcp', inst, 'addnhosts', addnhostsFile);
+               confdir = _dnsmasq_instance_get_confdir(inst);
+               if (confdir) unlink(confdir + '/' + pkg.name);
+               dhcp.list_remove('dhcp', inst, 'addnmount', confFile);
+               if (dhcp.get('dhcp', inst, 'serversfile') != serversFile)
+                       dhcp.set('dhcp', inst, 'serversfile', serversFile);
+               break;
+       }
+       dhcp.save('dhcp');
+}
+
+function _dnsmasq_instance_append_force_dns_port(inst) {
+       if (!stat('/etc/config/dhcp')?.size) return;
+       let dhcp = uci('dhcp');
+       if (!dhcp.get('dhcp', inst)) return;
+       let instance_port = dhcp.get('dhcp', inst, 'port') ?? '53';
+       if (!str_contains_word(cfg.force_dns_port, instance_port))
+               cfg.force_dns_port = (cfg.force_dns_port ? cfg.force_dns_port + ' ' : '') + instance_port;
+}
+
+function _smartdns_instance_config(inst, param) {
+       if (!stat('/etc/config/smartdns')?.size) return;
+       let sdns = uci('smartdns');
+       if (!sdns.get('smartdns', inst)) return;
+       switch (param) {
+       case 'cleanup':
+               sdns.list_remove('smartdns', inst, 'conf_files', dns_output.config);
+               sdns.save('smartdns');
+               unlink(dns_output.config);
+               break;
+       case 'smartdns.domainset':
+               writefile(dns_output.config,
+                       'domain-set -name adblock-fast -file ' + dns_output.file + '\n' +
+                       'domain-rules /domain-set:adblock-fast/ -a #\n');
+               uci_list_add_if_new('smartdns', inst, 'conf_files', dns_output.config);
+               break;
+       case 'smartdns.ipset':
+               writefile(dns_output.config,
+                       'domain-set -name adblock-fast -file ' + dns_output.file + '\n' +
+                       'domain-rules /domain-set:adblock-fast/ -ipset adb\n');
+               uci_list_add_if_new('smartdns', inst, 'conf_files', dns_output.config);
+               break;
+       case 'smartdns.nftset':
+               let nftset = '#4:inet#fw4#adb4';
+               if (cfg.ipv6_enabled) nftset += ',#6:inet#fw4#adb6';
+               writefile(dns_output.config,
+                       'domain-set -name adblock-fast -file ' + dns_output.file + '\n' +
+                       'domain-rules /domain-set:adblock-fast/ -nftset ' + nftset + '\n');
+               uci_list_add_if_new('smartdns', inst, 'conf_files', dns_output.config);
+               break;
+       }
+}
+
+function _smartdns_instance_append_force_dns_port(inst) {
+       if (!stat('/etc/config/smartdns')?.size) return;
+       let sdns = uci('smartdns');
+       if (!sdns.get('smartdns', inst)) return;
+       let instance_port = sdns.get('smartdns', inst, 'port') ?? '53';
+       if (!str_contains_word(cfg.force_dns_port, instance_port))
+               cfg.force_dns_port = (cfg.force_dns_port ? cfg.force_dns_port + ' ' : '') + instance_port;
+}
+
+function _unbound_instance_append_force_dns_port(inst) {
+       if (!stat('/etc/config/unbound')?.size) return;
+       let ubnd = uci('unbound');
+       if (!ubnd.get('unbound', inst)) return;
+       let instance_port = ubnd.get('unbound', inst, 'listen_port') ?? '53';
+       if (!str_contains_word(cfg.force_dns_port, instance_port))
+               cfg.force_dns_port = (cfg.force_dns_port ? cfg.force_dns_port + ' ' : '') + instance_port;
+}
+
+function _get_dnsmasq_instances() {
+       let result = [];
+       let dhcp_cur = cursor();
+       dhcp_cur.load('dhcp');
+       if (cfg.dnsmasq_instance == '*') {
+               dhcp_cur.foreach('dhcp', 'dnsmasq', (s) => push(result, s['.name']));
+       } else if (cfg.dnsmasq_instance) {
+               for (let inst in split('' + cfg.dnsmasq_instance, /\s+/)) {
+                       if (!inst) continue;
+                       // Try @dnsmasq[N] index style, resolve to section name
+                       let s = dhcp_cur.get_all('dhcp', '@dnsmasq[' + inst + ']');
+                       push(result, s?.['.name'] || inst);
+               }
+       }
+       return result;
+}
+
+function _get_smartdns_instances() {
+       let result = [];
+       let sdns_cur = cursor();
+       sdns_cur.load('smartdns');
+       if (cfg.smartdns_instance == '*') {
+               sdns_cur.foreach('smartdns', 'smartdns', (s) => push(result, s['.name']));
+       } else if (cfg.smartdns_instance) {
+               for (let inst in split('' + cfg.smartdns_instance, /\s+/)) {
+                       if (!inst) continue;
+                       let s = sdns_cur.get_all('smartdns', '@smartdns[' + inst + ']');
+                       push(result, s?.['.name'] || inst);
+               }
+       }
+       return result;
+}
+
+function resolver(action) {
+       let resolver_name = split(cfg.dns, '.')[0];
+       if (!action) return true;
+
+       switch (action) {
+       case 'cleanup':
+               for (let mode in dns_modes) {
+                       let dc = dns_modes[mode];
+                       unlink(dc.cache);
+                       unlink(cfg.compressed_cache_dir + '/' + dc.gzip);
+                       if (dc.file != pkg.dnsmasq_file) unlink(dc.file);
+                       if (dc.config) unlink(dc.config);
+               }
+               if (stat('/etc/config/dhcp')?.size) {
+                       for (let name in _get_dnsmasq_instances())
+                               _dnsmasq_instance_config(name, 'cleanup');
+                       if (uci_has_changes('dhcp')) uci('dhcp').commit('dhcp');
+               }
+               if (stat('/etc/config/smartdns')?.size) {
+                       for (let name in _get_smartdns_instances())
+                               _smartdns_instance_config(name, 'cleanup');
+                       if (uci_has_changes('smartdns')) uci('smartdns').commit('smartdns');
+               }
+               break;
+
+       case 'on_stop':
+       case 'quiet':
+       case 'quiet_restart':
+               return service_restart(resolver_name);
+
+       case 'on_start':
+               if (!adb_file('test')) {
+                       status_data.status = 'statusFail';
+                       push(status_data.errors, { code: 'errorOutputFileCreate', info: dns_output.file });
+                       return false;
+               }
+               output.info('Cycling ' + resolver_name + ' ');
+               if (resolver('update_config') && resolver('test') && resolver('sanity') && resolver('restart') && resolver('heartbeat')) {
+                       // success
+               } else {
+                       resolver('revert');
+               }
+               output.info('\\n');
+               break;
+
+       case 'test':
+               switch (cfg.dns) {
+               case 'dnsmasq.addnhosts':
+               case 'dnsmasq.conf':
+               case 'dnsmasq.ipset':
+               case 'dnsmasq.nftset':
+               case 'dnsmasq.servers':
+                       output.dns('Testing ' + cfg.dns + ' configuration ');
+                       if (cmd_rc('dnsmasq --test') == 0) {
+                               output.ok();
+                               return true;
+                       }
+                       output.fail();
+                       return false;
+               default:
+                       return true;
+               }
+
+       case 'restart':
+               output.dns('Restarting ' + resolver_name + ' ');
+               status_data.message = 'Restarting ' + resolver_name;
+               if (service_restart(resolver_name)) {
+                       status_data.status = 'statusSuccess';
+                       led_on(cfg.led);
+                       output.ok();
+                       return true;
+               }
+               output.fail();
+               status_data.status = 'statusFail';
+               push(status_data.errors, { code: 'errorDNSReload', info: '' });
+               return false;
+
+       case 'sanity':
+               if (!cfg.dnsmasq_sanity_check) return true;
+               output.dns('Sanity check for ' + cfg.dns + ' TLDs ');
+               if (!grep_test('\\.|server:', dns_output.file, '-qvE')) {
+                       output.ok();
+               } else {
+                       push(status_data.warnings, { code: 'warningSanityCheckTLD', info: dns_output.file });
+                       output.warn();
+               }
+               output.dns('Sanity check for ' + cfg.dns + ' leading dots ');
+               let dot_pattern;
+               switch (split(cfg.dns, '.')[0]) {
+               case 'dnsmasq': dot_pattern = '/\\.'; break;
+               case 'smartdns': dot_pattern = '^\\.'; break;
+               case 'unbound': dot_pattern = '"\\.'; break;
+               }
+               if (dot_pattern && !grep_test(dot_pattern, dns_output.file)) {
+                       output.ok();
+               } else {
+                       push(status_data.warnings, { code: 'warningSanityCheckLeadingDot', info: dns_output.file });
+                       output.warn();
+               }
+               return true;
+
+       case 'heartbeat':
+               if (!cfg.heartbeat_domain) return true;
+               if (!is_integer(cfg.heartbeat_sleep_timeout)) return true;
+               output.dns('Probing ' + cfg.heartbeat_domain + ' for ' + cfg.heartbeat_sleep_timeout + ' seconds ');
+               status_data.message = 'Testing resolver on ' + cfg.heartbeat_domain;
+               let timeout = int(cfg.heartbeat_sleep_timeout);
+               for (let i = 0; i < timeout; i++) {
+                       if (cmd_rc('resolveip ' + shell_quote(cfg.heartbeat_domain)) == 0) {
+                               output.ok();
+                               return true;
+                       }
+                       output.dot();
+                       system('sleep 1');
+               }
+               output.fail();
+               status_data.status = 'statusFail';
+               push(status_data.errors, { code: 'errorNoHeartbeat', info: '' });
+               return false;
+
+       case 'revert':
+               output.info('Resetting/Restarting ' + resolver_name + ' ');
+               output.dns('Resetting ' + resolver_name + ' ');
+               resolver('cleanup');
+               output.ok();
+               output.dns('Restarting ' + resolver_name + ' ');
+               if (service_restart(resolver_name)) {
+                       led_off(cfg.led);
+                       output.ok();
+                       return true;
+               }
+               output.fail();
+               status_data.status = 'statusFail';
+               push(status_data.errors, { code: 'errorDNSReload', info: '' });
+               return false;
+
+       case 'update_config':
+               output.dns('Updating ' + resolver_name + ' configuration ');
+               switch (split(cfg.dns, '.')[0]) {
+               case 'dnsmasq':
+                       for (let name in _get_dnsmasq_instances()) {
+                               _dnsmasq_instance_config(name, cfg.dns);
+                               _dnsmasq_instance_append_force_dns_port(name);
+                       }
+                       if (uci_has_changes('dhcp')) uci('dhcp').commit('dhcp');
+                       if (adb_file('test')) {
+                               chmod(dns_output.file, 0660);
+                               chown(dns_output.file, 'root', 'dnsmasq');
+                       } else {
+                               status_data.status = 'statusFail';
+                               push(status_data.errors, { code: 'errorNoOutputFile', info: dns_output.file });
+                               return false;
+                       }
+                       break;
+               case 'smartdns':
+                       for (let name in _get_smartdns_instances()) {
+                               _smartdns_instance_config(name, cfg.dns);
+                               _smartdns_instance_append_force_dns_port(name);
+                       }
+                       if (uci_has_changes('smartdns')) uci('smartdns').commit('smartdns');
+                       chmod(dns_output.file, 0660);
+                       chmod(dns_output.config, 0660);
+                       chown(dns_output.file, 'root', 'root');
+                       chown(dns_output.config, 'root', 'root');
+                       break;
+               case 'unbound':
+                       let ubnd_cur = cursor();
+                       ubnd_cur.load('unbound');
+                       ubnd_cur.foreach('unbound', 'unbound', (s) => _unbound_instance_append_force_dns_port(s['.name']));
+                       chmod(dns_output.file, 0660);
+                       chown(dns_output.file, 'root', 'unbound');
+                       break;
+               }
+               output.ok();
+               return true;
+       }
+       return true;
+}
+
+// ── process_file_url ────────────────────────────────────────────────
+
+function process_file_url(section, url_override, action_override) {
+       let url, file_action, name, size_val;
+
+       if (section && !url_override) {
+               let sec_cur = cursor();
+               sec_cur.load(pkg.name);
+               let en = sec_cur.get(pkg.name, section, 'enabled');
+               if (en == '0') return true;
+               url = sec_cur.get(pkg.name, section, 'url');
+               file_action = sec_cur.get(pkg.name, section, 'action') || 'block';
+               name = sec_cur.get(pkg.name, section, 'name');
+               size_val = sec_cur.get(pkg.name, section, 'size');
+       } else {
+               url = url_override;
+               file_action = action_override || 'block';
+       }
+
+       if (!cfg.enabled) return true;
+       if (!url) return false;
+
+       let label = replace(url, /^[a-z]+:\/\//, '');
+       label = replace(label, /\/.*$/, '');
+       label = name || label;
+       label = 'List: ' + label;
+
+       let type_name, d_tmp;
+       switch (file_action) {
+       case 'allow': type_name = 'Allowed'; d_tmp = tmp.allowed; break;
+       case 'block': type_name = 'Blocked'; d_tmp = tmp.b; break;
+       case 'file': type_name = 'File'; d_tmp = tmp.b; break;
+       }
+
+       if (is_https_url(url) && !env.get_downloader().ssl_supported) {
+               output.info(sym.fail[0]);
+               output.verbose('[ DL ] ' + type_name + ' ' + label + ' ' + sym.fail[1] + '\\n');
+               push(status_data.errors, { code: 'errorNoSSLSupport', info: name || url });
+               return true;
+       }
+
+       let r_tmp = trim(cmd_output('mktemp -q -t "' + pkg.name + '_tmp.XXXXXXXX"'));
+       if (!url || !download(url, r_tmp) || !(stat(r_tmp)?.size > 0)) {
+               output.info(sym.fail[0]);
+               output.verbose('[ DL ] ' + type_name + ' ' + label + ' ' + sym.fail[1] + '\\n');
+               push(status_data.errors, { code: 'errorDownloadingList', info: name || url });
+       } else {
+               // Ensure newline at end
+               ensure_trailing_newline(r_tmp);
+
+               // Update size in config if changed
+               if (section) {
+                       let new_size = get_local_filesize(r_tmp);
+                       if (new_size != null && ('' + size_val) != ('' + new_size))
+                               uci(pkg.name).set(pkg.name, section, 'size', '' + new_size);
+                       uci(pkg.name).save(pkg.name);
+               }
+
+               let format = detect_file_type(r_tmp);
+               let filter = list_formats[format]?.filter;
+               if (!filter) {
+                       output.info(sym.fail[0]);
+                       output.verbose('[ DL ] ' + type_name + ' ' + label + ' ' + sym.fail[1] + '\\n');
+                       push(status_data.errors, { code: 'errorDetectingFileType', info: name || url });
+                       unlink(r_tmp);
+                       return true;
+               }
+               if (format == 'hosts')
+                       sed_inplace('/# Title: StevenBlack/,/# Custom host records are listed here/d', r_tmp);
+
+               if (filter && file_action != 'file')
+                       sed_inplace(filter, r_tmp);
+
+               if (!(stat(r_tmp)?.size > 0)) {
+                       output.info(sym.fail[0]);
+                       output.verbose('[ DL ] ' + type_name + ' ' + label + ' (' + format + ') ' + sym.fail[1] + '\\n');
+                       push(status_data.errors, { code: 'errorParsingList', info: name || url });
+               } else {
+                       // Ensure file ends with newline, then append to accumulator
+                       ensure_trailing_newline(r_tmp);
+                       let inp = open(r_tmp, 'r');
+                       let out = open(d_tmp, 'a');
+                       if (inp && out) {
+                               let chunk;
+                               while ((chunk = inp.read(65536)) && length(chunk))
+                                       out.write(chunk);
+                       }
+                       if (inp) inp.close();
+                       if (out) out.close();
+                       output.info(sym.ok[0]);
+                       output.verbose('[ DL ] ' + type_name + ' ' + label + ' (' + format + ') ' + sym.ok[1] + '\\n');
+               }
+       }
+       unlink(r_tmp);
+       return true;
+}
+
+// ── download_dnsmasq_file ───────────────────────────────────────────
+
+function download_dnsmasq_file() {
+       status_data.message = get_text('statusDownloading') + '...';
+       status_data.status = 'statusDownloading';
+
+       for (let f in [tmp.allowed, tmp.a, tmp.b, tmp.sed, dns_output.file, dns_output.cache]) unlink(f);
+       if (get_mem_available() < pkg.memory_threshold) {
+               output.print('Low free memory, restarting resolver ');
+               if (resolver('quiet_restart')) output.okn(); else output.failn();
+       }
+       for (let f in [tmp.allowed, tmp.a, tmp.b, tmp.sed]) writefile(f, '');
+       output.info('Downloading dnsmasq file ');
+       process_file_url(null, cfg.dnsmasq_config_file_url, 'file');
+       output.dns('Moving dnsmasq file ');
+       if (rename(tmp.b, dns_output.file)) {
+               output.ok();
+       } else {
+               output.fail();
+               push(status_data.errors, { code: 'errorMovingDataFile', info: dns_output.file });
+       }
+       output.info('\\n');
+}
+
+// ── download_lists ──────────────────────────────────────────────────
+
+function download_lists() {
+       // RAM check
+       let free_mem = get_mem_available();
+       if (!free_mem) {
+               push(status_data.warnings, { code: 'warningFreeRamCheckFail', info: '' });
+               output.warning(get_text('warningFreeRamCheckFail'));
+       } else {
+               let total_sizes = 0;
+               uci(pkg.name).foreach(pkg.name, 'file_url', (s) => {
+                       if (s.enabled == '0') return;
+                       let sz = s.size;
+                       if (!sz && s.url) sz = get_url_filesize(s.url);
+                       if (sz) total_sizes += int('' + sz);
+               });
+               if (free_mem < total_sizes * 2) {
+                       push(status_data.errors, { code: 'errorTooLittleRam', info: '' + free_mem });
+                       return false;
+               }
+       }
+
+       status_data.message = get_text('statusDownloading') + '...';
+       status_data.status = 'statusDownloading';
+
+       for (let f in [tmp.allowed, tmp.a, tmp.b, tmp.sed, dns_output.file, dns_output.cache, dns_output.gzip]) unlink(f);
+       if (get_mem_total() < pkg.memory_threshold) {
+               output.print('Low free memory, restarting resolver ');
+               if (resolver('quiet_restart')) output.okn(); else output.failn();
+       }
+       for (let f in [tmp.allowed, tmp.a, tmp.b, tmp.sed]) writefile(f, '');
+
+       output.info('Downloading lists ');
+
+       // Process each file_url section
+       let download_cfgs = [];
+       uci(pkg.name).foreach(pkg.name, 'file_url', (s) => push(download_cfgs, s['.name']));
+
+       for (let cfg_name in download_cfgs)
+               process_file_url(cfg_name);
+
+       if (uci_has_changes(pkg.name)) {
+               output.verbose('[PROC] Saving updated file sizes ');
+               if (cfg.update_config_sizes && uci(pkg.name).commit(pkg.name))
+                       output.ok();
+               else
+                       output.fail();
+       }
+       output.info('\\n');
+
+       // Add canary domains and cfg.blocked_domain
+       let canaryDomains = '';
+       if (cfg.canary_domains_icloud)
+               canaryDomains += (canaryDomains ? ' ' : '') + canary.icloud;
+       if (cfg.canary_domains_mozilla)
+               canaryDomains += (canaryDomains ? ' ' : '') + canary.mozilla;
+
+       output.info('Processing downloads ');
+
+       let start_time, end_time, elapsed, step_title;
+
+       // Sort combined block-list
+       start_time = time();
+       step_title = 'Sorting combined block-list';
+       output.verbose('[PROC] ' + step_title + ' ');
+       status_data.status = 'statusProcessing';
+       status_data.message = get_text('statusProcessing') + ': ' + step_title;
+
+       // Append cfg.blocked_domain and canary domains
+       ensure_trailing_newline(tmp.b);
+       let extra_domains = '';
+       for (let hf in split((cfg.blocked_domain || '') + ' ' + canaryDomains, /\s+/)) {
+               if (hf) extra_domains += hf + '\n';
+       }
+       if (extra_domains) {
+               let fd = popen(sprintf("sed %s >> %s", shell_quote(list_formats.domains.filter), shell_quote(tmp.b)), 'w');
+               if (fd) { fd.write(extra_domains); fd.close(); }
+       }
+       sed_inplace('/^[[:space:]]*$/d', tmp.b);
+
+       if (!(stat(tmp.b)?.size > 0)) return false;
+
+       if (cfg.allow_non_ascii) {
+               if (sort_file(tmp.b, tmp.a, true))
+                       output.ok();
+               else { output.fail(); push(status_data.errors, { code: 'errorSorting', info: '' }); }
+       } else {
+               if (system(sprintf("sort -u %s | grep -E -v '[^a-zA-Z0-9=/.-]' > %s", shell_quote(tmp.b), shell_quote(tmp.a))) == 0)
+                       output.ok();
+               else { output.fail(); push(status_data.errors, { code: 'errorSorting', info: '' }); }
+       }
+       end_time = time();
+       elapsed = end_time - start_time;
+       logger_debug('[PERF-DEBUG] ' + step_title + ' took ' + elapsed + 's');
+
+       // Optimization (subdomain dedup)
+       let needs_optimization = (cfg.dns == 'dnsmasq.conf' || cfg.dns == 'dnsmasq.ipset' || cfg.dns == 'dnsmasq.nftset' ||
+               cfg.dns == 'dnsmasq.servers' || cfg.dns == 'smartdns.domainset' || cfg.dns == 'smartdns.ipset' ||
+               cfg.dns == 'smartdns.nftset' || cfg.dns == 'unbound.adb_list');
+
+       if (needs_optimization) {
+               start_time = time();
+               step_title = 'Optimizing combined block-list';
+               output.verbose('[PROC] ' + step_title + ' ');
+               status_data.message = get_text('statusProcessing') + ': ' + step_title;
+
+               let ok = awk_reverse_labels(tmp.a, tmp.b);
+               if (ok) ok = sort_file(tmp.b, tmp.a);
+               if (ok) ok = awk_dedup_subdomains(tmp.a, tmp.b);
+               if (ok) ok = awk_reverse_labels(tmp.b, tmp.a);
+               if (ok) ok = sort_file(tmp.a, tmp.b, true);
+               if (ok) { output.ok(); }
+               else {
+                       output.fail();
+                       push(status_data.errors, { code: 'errorOptimization', info: '' });
+                       rename(tmp.a, tmp.b);
+               }
+               end_time = time();
+               elapsed = end_time - start_time;
+               logger_debug('[PERF-DEBUG] ' + step_title + ' took ' + elapsed + 's');
+       } else {
+               rename(tmp.a, tmp.b);
+       }
+
+       // Remove allowed domains
+       if (cfg.allowed_domain || (stat(tmp.allowed)?.size > 0)) {
+               start_time = time();
+               step_title = 'Removing allowed domains from combined block-list';
+               output.verbose('[PROC] ' + step_title + ' ');
+               status_data.message = get_text('statusProcessing') + ': ' + step_title;
+
+               let allowed_extra = '';
+               if (stat(tmp.allowed)?.size > 0)
+                       allowed_extra = trim(cmd_output(sprintf("sed '/^[[:space:]]*$/d' %s", shell_quote(tmp.allowed))));
+               let all_allowed = (cfg.allowed_domain || '') + (allowed_extra ? ' ' + allowed_extra : '');
+
+               let sed_content = '';
+               for (let hf in split(all_allowed, /\s+/)) {
+                       if (!hf) continue;
+                       let escaped = replace(hf, /\./g, '\\.');
+                       sed_content += '/(^|\\.)' + escaped + '$/d;\n';
+               }
+               if (sed_content) {
+                       writefile(tmp.sed, sed_content);
+                       if (sed_script(tmp.sed, tmp.b, tmp.a) && rename(tmp.a, tmp.b))
+                               output.ok();
+                       else { output.fail(); push(status_data.errors, { code: 'errorAllowListProcessing', info: '' }); }
+               } else {
+                       output.fail();
+                       push(status_data.errors, { code: 'errorAllowListProcessing', info: '' });
+               }
+               end_time = time();
+               elapsed = end_time - start_time;
+               logger_debug('[PERF-DEBUG] ' + step_title + ' took ' + elapsed + 's');
+       }
+
+       // Format combined block-list
+       start_time = time();
+       step_title = 'Formatting combined block-list file';
+       output.verbose('[PROC] ' + step_title + ' ');
+       status_data.message = get_text('statusProcessing') + ': ' + step_title;
+
+       if (!dns_output.filter_ipv6) {
+               if (dns_output.filter) {
+                       if (sed_filter(dns_output.filter, tmp.b, tmp.a))
+                               output.ok();
+                       else { output.fail(); push(status_data.errors, { code: 'errorDataFileFormatting', info: '' }); }
+               } else {
+                       writefile(tmp.a, readfile(tmp.b) || '');
+                       output.ok();
+               }
+       } else {
+               if (cfg.dns == 'dnsmasq.addnhosts') {
+                       if (sed_filter(dns_output.filter, tmp.b, tmp.a) &&
+                               system(sprintf('sed %s %s >> %s', shell_quote(dns_output.filter_ipv6), shell_quote(tmp.b), shell_quote(tmp.a))) == 0)
+                               output.ok();
+                       else { output.fail(); push(status_data.errors, { code: 'errorDataFileFormatting', info: '' }); }
+               }
+       }
+       end_time = time();
+       elapsed = end_time - start_time;
+       logger_debug('[PERF-DEBUG] ' + step_title + ' took ' + elapsed + 's');
+
+       // Explicitly allow domains in servers mode
+       if (dns_output.allow_filter && cfg.allowed_domain) {
+               unlink(tmp.sed); writefile(tmp.sed, '');
+               start_time = time();
+               step_title = 'Explicitly allowing domains in ' + cfg.dns;
+               output.verbose('[PROC] ' + step_title + ' ');
+               status_data.message = get_text('statusProcessing') + ': ' + step_title;
+               let allow_input = '';
+               for (let hf in split('' + cfg.allowed_domain, /\s+/))
+                       if (hf) allow_input += hf + '\n';
+               if (allow_input)
+                       system(sprintf("printf '%%s' %s | sed -E '%s' >> %s", shell_quote(allow_input), dns_output.allow_filter, shell_quote(tmp.sed)));
+               if (stat(tmp.sed)?.size > 0) {
+                       if (writefile(tmp.b, (readfile(tmp.sed) || '') + (readfile(tmp.a) || '')))
+                               output.ok();
+                       else { output.fail(); push(status_data.errors, { code: 'errorAllowListProcessing', info: '' }); }
+               } else {
+                       output.fail();
+                       push(status_data.errors, { code: 'errorAllowListProcessing', info: '' });
+               }
+               end_time = time();
+               elapsed = end_time - start_time;
+               logger_debug('[PERF-DEBUG] ' + step_title + ' took ' + elapsed + 's');
+       } else {
+               rename(tmp.a, tmp.b);
+       }
+
+       // Move to output file
+       start_time = time();
+       step_title = 'Setting up ' + cfg.dns + ' file';
+       output.verbose('[PROC] ' + step_title + ' ');
+       status_data.message = get_text('statusProcessing') + ': ' + step_title;
+
+       if (rename(tmp.b, dns_output.file)) {
+               output.ok();
+       } else {
+               output.fail();
+               push(status_data.errors, { code: 'errorMovingDataFile', info: dns_output.file });
+       }
+       if (cfg.dns == 'unbound.adb_list')
+               sed_inplace('1 i\\server:', dns_output.file);
+
+       // Validity check
+       if (cfg.dnsmasq_validity_check && index(cfg.dns, 'dnsmasq.') == 0) {
+               start_time = time();
+               step_title = 'Validating domain entries';
+               output.verbose('[PROC] ' + step_title + ' ');
+               status_data.message = get_text('statusProcessing') + ': ' + step_title;
+               let invalid_file = '/tmp/' + pkg.name + '.invalid.tmp';
+               unlink(invalid_file);
+               system(sprintf("sed '%s' %s | grep -E '^-|^\\.|^[0-9.]+$|\\.\\.|\\-$|\\.$' > %s 2>/dev/null || true",
+                       dns_output.parse_filter, shell_quote(dns_output.file), shell_quote(invalid_file)));
+               let invalid_count = 0;
+               if (stat(invalid_file)?.size > 0) {
+                       invalid_count = int(trim(cmd_output('wc -l < ' + shell_quote(invalid_file))) || '0');
+                       if (invalid_count > 0) {
+                               let dc = dns_modes[cfg.dns];
+                               let grep_pat = dc ? dc.grep_pattern : null;
+                               if (cfg.dns == 'dnsmasq.addnhosts' && dc) {
+                                       system(sprintf("{ sed '%s' %s; sed '%s' %s; } > %s.pat 2>/dev/null",
+                                               dc.grep_pattern_ipv4, shell_quote(invalid_file),
+                                               dc.grep_pattern_ipv6, shell_quote(invalid_file),
+                                               shell_quote(invalid_file)));
+                                       grep_pat = null;
+                               }
+                               if (grep_pat)
+                                       sed_filter(grep_pat, invalid_file, invalid_file + '.pat');
+                               grep_exclude_file(invalid_file + '.pat', dns_output.file, dns_output.file + '.valid');
+                               rename(dns_output.file + '.valid', dns_output.file);
+                               logger(sprintf('Removed %d invalid entries from %s.', invalid_count, cfg.dns));
+                               push(status_data.warnings, { code: 'warningInvalidDomainsRemoved', info: '' + invalid_count });
+                               unlink(invalid_file + '.pat');
+                       }
+                       unlink(invalid_file);
+               }
+               if (invalid_count > 0) output.warn(); else output.ok();
+               end_time = time();
+               elapsed = end_time - start_time;
+               logger_debug('[PERF-DEBUG] ' + step_title + ' took ' + elapsed + 's');
+       }
+
+       // Remove temporary files
+       step_title = 'Removing temporary files';
+       output.verbose('[PROC] ' + step_title + ' ');
+       status_data.message = get_text('statusProcessing') + ': ' + step_title;
+       for (let f in glob('/tmp/' + pkg.name + '_tmp.*') || []) unlink(f);
+       for (let f in [tmp.allowed, tmp.a, tmp.b, tmp.sed, dns_output.cache]) unlink(f);
+       output.ok();
+       output.info('\\n');
+       return true;
+}
+
+// ── adb_config_update ───────────────────────────────────────────────
+
+function adb_config_update(param) {
+       param = param || 'quiet';
+       env.load_config();
+       let label = replace('' + cfg.config_update_url, /^[a-z]+:\/\//, '');
+       label = replace(label, /\/.*$/, '');
+       if (!cfg.enabled) return;
+       if (!cfg.config_update_enabled) return;
+
+       if (param != 'download') {
+               if (adb_file('test')) return;
+               if (adb_file('test_cache')) return;
+               if (adb_file('test_gzip')) return;
+       }
+
+       output.info('Updating config ');
+       output.verbose('[ DL ] Config Update: ' + label + ' ');
+       let r_tmp = trim(cmd_output('mktemp -q -t "' + pkg.name + '_tmp.XXXXXXXX"'));
+       if (!download(cfg.config_update_url, r_tmp) || !(stat(r_tmp)?.size > 0)) {
+               output.failn();
+               push(status_data.errors, { code: 'errorDownloadingConfigUpdate', info: '' });
+       } else {
+               if (system(sprintf("sed -f %s -i %s 2>/dev/null", shell_quote(r_tmp), shell_quote(pkg.config_file))) == 0)
+                       output.okn();
+               else { output.failn(); push(status_data.errors, { code: 'errorParsingConfigUpdate', info: '' }); }
+       }
+       unlink(r_tmp);
+       // Cleanup missing URLs (refresh cursor after sed modified config)
+       let to_delete = [];
+       uci(pkg.name, true).foreach(pkg.name, 'file_url', (s) => {
+               if (!s.url) push(to_delete, s['.name']);
+       });
+       for (let name in to_delete)
+               uci(pkg.name).delete(pkg.name, name);
+       uci(pkg.name).save(pkg.name);
+       if (uci_has_changes(pkg.name))
+               uci(pkg.name).commit(pkg.name);
+}
+
+// ── get_file_url_list ────────────────────────────────────────────────
+
+function get_file_url_list() {
+       let files = [];
+       uci(pkg.name).foreach(pkg.name, 'file_url', (s) => {
+               let size = s.size;
+               if (!size && s.url) size = get_url_filesize(s.url);
+               push(files, { name: s.name || s.url, url: s.url, size: size || '' });
+       });
+       return files;
+}
+
+// ── _build_procd_data ───────────────────────────────────────────────
+
+function _build_procd_data() {
+       let result = {};
+       result.version = pkg.version;
+       result.status = status_data.status;
+       result.message = status_data.message;
+       result.stats = status_data.stats;
+       result.packageCompat = int(pkg.compat);
+       result.entries = int(count_blocked_domains());
+       result.dns = cfg.dns;
+       result.outputFile = dns_output.file;
+       result.outputCache = dns_output.cache;
+
+       let gzip_path = cfg.compressed_cache_dir
+               ? cfg.compressed_cache_dir + '/' + dns_output.gzip
+               : '';
+       result.outputGzip = gzip_path;
+
+       // Force DNS
+       let force_dns_ports = [];
+       if (cfg.force_dns && cfg.force_dns_port) {
+               force_dns_ports = split('' + cfg.force_dns_port, /[\s,]+/);
+       }
+       result.force_dns_active = length(force_dns_ports) > 0;
+       result.force_dns_ports = force_dns_ports;
+
+       // Platform support
+       result.platform = {
+               ipset_installed: env.ipset_supported,
+               nft_installed: env.nft_installed,
+               dnsmasq_installed: env.dnsmasq_installed,
+               dnsmasq_ipset_support: env.check_dnsmasq_ipset(),
+               dnsmasq_nftset_support: env.check_dnsmasq_nftset(),
+               smartdns_installed: env.smartdns_installed,
+               smartdns_ipset_support: env.smartdns_installed && env.ipset_supported,
+               smartdns_nftset_support: env.smartdns_installed && env.nft_installed,
+               unbound_installed: env.unbound_installed,
+               leds: lsdir('/sys/class/leds') || [],
+       };
+
+       // File URL sizes
+       result.file_url = get_file_url_list();
+
+       // Errors
+       result.errors = [];
+       for (let e in status_data.errors)
+               push(result.errors, { code: e.code, info: e.info });
+
+       // Warnings
+       result.warnings = [];
+       for (let e in status_data.warnings)
+               push(result.warnings, { code: e.code, info: e.info });
+
+       // Firewall rules
+       result.firewall = [];
+       if (cfg.force_dns) {
+               let ports = split(replace('' + cfg.force_dns_port, /,/g, ' '), /\s+/);
+               for (let p in ports) {
+                       if (!p) continue;
+                       let ifaces = split('' + cfg.force_dns_interface, /\s+/);
+                       if (is_port_listening(p)) {
+                               for (let iface in ifaces) {
+                                       if (!iface) continue;
+                                       push(result.firewall, {
+                                               type: 'redirect', target: 'DNAT', src: iface,
+                                               proto: 'tcp udp', src_dport: '53', dest_port: '' + p,
+                                               family: 'any', reflection: false,
+                                       });
+                               }
+                       } else {
+                               for (let iface in ifaces) {
+                                       if (!iface) continue;
+                                       push(result.firewall, {
+                                               type: 'rule', src: iface, dest: '*',
+                                               proto: 'tcp udp', dest_port: '' + p, target: 'REJECT',
+                                       });
+                               }
+                       }
+               }
+       }
+
+       // fw4 restart flag (consumed by init script as shell variable)
+       result.fw4_restart_needed = is_fw4_restart_needed();
+
+       // ipset/nftset firewall rules
+       switch (cfg.dns) {
+       case 'dnsmasq.ipset':
+       case 'smartdns.ipset':
+               push(result.firewall, { type: 'ipset', name: 'adb', match: 'dest_net', storage: 'hash' });
+               for (let iface in split('' + (cfg.force_dns_interface), /\s+/)) {
+                       if (!iface) continue;
+                       push(result.firewall, { type: 'rule', ipset: 'adb', src: iface, dest: '*', proto: 'tcp udp', target: 'REJECT' });
+               }
+               break;
+       case 'dnsmasq.nftset':
+       case 'smartdns.nftset':
+               push(result.firewall, { type: 'ipset', name: 'adb4', family: '4', match: 'dest_net' });
+               for (let iface in split('' + (cfg.force_dns_interface), /\s+/)) {
+                       if (!iface) continue;
+                       push(result.firewall, { type: 'rule', ipset: 'adb4', src: iface, dest: '*', proto: 'tcp udp', target: 'REJECT' });
+               }
+               if (cfg.ipv6_enabled) {
+                       push(result.firewall, { type: 'ipset', name: 'adb6', family: '6', match: 'dest_net' });
+                       for (let iface in split('' + (cfg.force_dns_interface), /\s+/)) {
+                               if (!iface) continue;
+                               push(result.firewall, { type: 'rule', ipset: 'adb6', src: iface, dest: '*', proto: 'tcp udp', target: 'REJECT' });
+                       }
+               }
+               break;
+       }
+
+       return result;
+}
+
+// ── emit_procd_shell ────────────────────────────────────────────────
+// Converts _build_procd_data() result into json_add_* shell commands
+// for safe use between procd_open_data / procd_close_data.
+
+function emit_procd_shell(data) {
+       if (!data) return '';
+       let lines = [];
+
+       if (data.fw4_restart_needed)
+               push(lines, '_fw4_restart=1');
+
+       // Minimal data (e.g. from stop) — only emit shell variables
+       if (!data.version)
+               return join('\n', lines) + '\n';
+
+       push(lines, 'json_add_string version ' + shell_quote(data.version || ''));
+       push(lines, 'json_add_string status ' + shell_quote(data.status || ''));
+       push(lines, 'json_add_string message ' + shell_quote(data.message || ''));
+       push(lines, 'json_add_string stats ' + shell_quote(data.stats || ''));
+       push(lines, 'json_add_int packageCompat ' + shell_quote('' + (data.packageCompat || 0)));
+       push(lines, 'json_add_int entries ' + shell_quote('' + (data.entries || 0)));
+       push(lines, 'json_add_string dns ' + shell_quote(data.dns || ''));
+       push(lines, 'json_add_string outputFile ' + shell_quote(data.outputFile || ''));
+       push(lines, 'json_add_string outputCache ' + shell_quote(data.outputCache || ''));
+       push(lines, 'json_add_string outputGzip ' + shell_quote(data.outputGzip || ''));
+       push(lines, 'json_add_boolean force_dns_active ' + shell_quote(data.force_dns_active ? '1' : '0'));
+
+       push(lines, 'json_add_array force_dns_ports');
+       for (let p in (data.force_dns_ports || []))
+               push(lines, 'json_add_string \'\' ' + shell_quote('' + p));
+       push(lines, 'json_close_array');
+
+       // Platform support
+       push(lines, 'json_add_object platform');
+       let plat = data.platform || {};
+       push(lines, 'json_add_boolean ipset_installed ' + shell_quote(plat.ipset_installed ? '1' : '0'));
+       push(lines, 'json_add_boolean nft_installed ' + shell_quote(plat.nft_installed ? '1' : '0'));
+       push(lines, 'json_add_boolean dnsmasq_installed ' + shell_quote(plat.dnsmasq_installed ? '1' : '0'));
+       push(lines, 'json_add_boolean dnsmasq_ipset_support ' + shell_quote(plat.dnsmasq_ipset_support ? '1' : '0'));
+       push(lines, 'json_add_boolean dnsmasq_nftset_support ' + shell_quote(plat.dnsmasq_nftset_support ? '1' : '0'));
+       push(lines, 'json_add_boolean smartdns_installed ' + shell_quote(plat.smartdns_installed ? '1' : '0'));
+       push(lines, 'json_add_boolean smartdns_ipset_support ' + shell_quote(plat.smartdns_ipset_support ? '1' : '0'));
+       push(lines, 'json_add_boolean smartdns_nftset_support ' + shell_quote(plat.smartdns_nftset_support ? '1' : '0'));
+       push(lines, 'json_add_boolean unbound_installed ' + shell_quote(plat.unbound_installed ? '1' : '0'));
+       push(lines, 'json_add_array leds');
+       for (let led in (plat.leds || []))
+               push(lines, 'json_add_string \'\' ' + shell_quote('' + led));
+       push(lines, 'json_close_array');
+       push(lines, 'json_close_object');
+
+       // File URL sizes
+       push(lines, 'json_add_array file_url');
+       for (let f in (data.file_url || [])) {
+               push(lines, "json_add_object ''");
+               push(lines, 'json_add_string name ' + shell_quote(f.name || ''));
+               push(lines, 'json_add_string url ' + shell_quote(f.url || ''));
+               push(lines, 'json_add_string size ' + shell_quote('' + (f.size || '')));
+               push(lines, 'json_close_object');
+       }
+       push(lines, 'json_close_array');
+
+       push(lines, 'json_add_array errors');
+       for (let e in (data.errors || [])) {
+               push(lines, "json_add_object ''");
+               push(lines, 'json_add_string code ' + shell_quote(e.code || ''));
+               push(lines, 'json_add_string info ' + shell_quote(e.info || ''));
+               push(lines, 'json_close_object');
+       }
+       push(lines, 'json_close_array');
+
+       push(lines, 'json_add_array warnings');
+       for (let w in (data.warnings || [])) {
+               push(lines, "json_add_object ''");
+               push(lines, 'json_add_string code ' + shell_quote(w.code || ''));
+               push(lines, 'json_add_string info ' + shell_quote(w.info || ''));
+               push(lines, 'json_close_object');
+       }
+       push(lines, 'json_close_array');
+
+       push(lines, 'json_add_array firewall');
+       for (let rule in (data.firewall || [])) {
+               push(lines, "json_add_object ''");
+               for (let k in keys(rule)) {
+                       let v = rule[k];
+                       if (type(v) == 'bool')
+                               push(lines, 'json_add_boolean ' + k + ' ' + shell_quote(v ? '1' : '0'));
+                       else if (type(v) == 'int')
+                               push(lines, 'json_add_int ' + k + ' ' + shell_quote('' + v));
+                       else
+                               push(lines, 'json_add_string ' + k + ' ' + shell_quote('' + v));
+               }
+               push(lines, 'json_close_object');
+       }
+       push(lines, 'json_close_array');
+
+       return join('\n', lines) + '\n';
+}
+
+// ── status_service ──────────────────────────────────────────────────
+
+function status_service(param) {
+       env.load_config();
+       // When called from start() the in-memory status_data is already correct;
+       // reloading from ubus would overwrite it with stale data.
+       if (param != 'on_start_success' && param != 'on_start_failure')
+               _load_status_from_ubus();
+       let status = status_data.status;
+       let message = status_data.message;
+       let stats = status_data.stats;
+
+       if (status == 'statusSuccess') {
+               output.info('* ' + stats + '\\n');
+               output.verbose('[STAT] ' + stats + '\\n');
+       } else {
+               if (status) status = get_text(status);
+               if (status && message) status += ': ' + message;
+               let cache_info = '';
+               let has_cache = adb_file('test_cache');
+               let has_gzip = adb_file('test_gzip');
+               if (has_cache && has_gzip) cache_info = 'cache file and compressed cache file found';
+               else if (has_cache) cache_info = 'cache file found';
+               else if (has_gzip) cache_info = 'compressed cache file found';
+               if (status && cache_info) status += ' (' + cache_info + ')';
+               if (status) output.print(pkg.service_name + ' ' + status + '.\\n');
+       }
+
+       if (param == 'quiet' || param == 'on_start_success' || param == 'on_start_failure') return;
+
+       for (let e in status_data.errors)
+               output.error(get_text(e.code, e.info));
+       for (let e in status_data.warnings)
+               output.warning(get_text(e.code, e.info));
+}
+
+// ── start ───────────────────────────────────────────────────────────
+// Returns JSON object for procd_open_data (status, firewall[], errors[], warnings[])
+
+function start(args) {
+       let param = (args && args[0]) || 'on_start';
+
+       _load_status_from_ubus();
+       let prev_status = status_data.status;
+       let prev_errors = length(status_data.errors) > 0;
+       _status_reset();
+
+       if (param == 'on_boot') {
+               env.load(param);  // on_boot: just loads config + dns_output
+               if (!adb_file('test_gzip') && !adb_file('test_cache'))
+                       return null;
+       }
+
+       adb_config_update(param);
+       if (!env.load(param)) return null;  // memoized if already called above
+
+       let action = adb_config_cache('get', 'trigger_service');
+       state.fw4_restart = adb_config_cache('get', 'trigger_fw4');
+
+       if (prev_errors) {
+               action = 'download';
+       } else if (!adb_file('test')) {
+               if (adb_file('test_gzip') || adb_file('test_cache'))
+                       action = 'restore';
+               else
+                       action = 'download';
+       } else if (prev_status == 'statusSuccess') {
+               action = 'skip';
+       }
+
+       // Normalize action based on param
+       let combo = (action || '') + ':' + param;
+       if (index(combo, 'on_boot') >= 0 || param == 'on_pause') {
+               action = (adb_file('test_gzip') || adb_file('test_cache')) ? 'restore' : 'download';
+       } else if (param == 'download' || action == 'download') {
+               action = 'download';
+       } else if (action == 'restart') {
+               action = 'restart';
+       } else if (action == 'restore') {
+               action = 'restore';
+       } else if (action == 'skip') {
+               action = 'skip';
+       } else if (!action) {
+               action = 'download';
+       }
+
+       if (action == 'restore') {
+               output.info('Starting ' + pkg.service_name + '...\\n');
+               output.verbose('[INIT] Starting ' + pkg.service_name + '...\\n');
+               status_data.status = 'statusStarting';
+               if (adb_file('test_gzip') && !adb_file('test_cache') && !adb_file('test')) {
+                       output.info('Found compressed cache file, unpacking it ');
+                       output.verbose('[INIT] Found compressed cache file, unpacking it ');
+                       status_data.message = 'found compressed cache file, unpacking it.';
+                       if (adb_file('unpack_gzip')) {
+                               output.okn();
+                       } else {
+                               output.failn();
+                               output.error(get_text('errorRestoreCompressedCache'));
+                               action = 'download';
+                       }
+               }
+               if (adb_file('test_cache') && !adb_file('test')) {
+                       output.info('Found cache file, reusing it ');
+                       output.verbose('[INIT] Found cache file, reusing it ');
+                       status_data.message = 'found cache file, reusing it.';
+                       if (adb_file('restore')) {
+                               cfg.dnsmasq_sanity_check = false;
+                               cfg.heartbeat_domain = null;
+                               output.okn();
+                               resolver('on_start');
+                       } else {
+                               output.failn();
+                               output.error(get_text('errorRestoreCache'));
+                               action = 'download';
+                       }
+               }
+       }
+
+       if (action == 'download') {
+               if (!cfg.blocked_url && !cfg.blocked_domain) {
+                       status_data.status = 'statusFail';
+                       push(status_data.errors, { code: 'errorNothingToDo', info: '' });
+               } else {
+                       if (!adb_file('test') || adb_file('test_cache') || adb_file('test_gzip')) {
+                               output.info('Force-reloading ' + pkg.service_name + '...\\n');
+                               output.verbose('[INIT] Force-reloading ' + pkg.service_name + '...\\n');
+                               status_data.status = 'statusForceReloading';
+                       } else {
+                               output.info('Starting ' + pkg.service_name + '...\\n');
+                               output.verbose('[INIT] Starting ' + pkg.service_name + '...\\n');
+                               status_data.status = 'statusStarting';
+                       }
+                       resolver('cleanup');
+                       if (cfg.dns == 'dnsmasq.conf' && cfg.dnsmasq_config_file_url)
+                               download_dnsmasq_file();
+                       else
+                               download_lists();
+                       resolver('on_start');
+               }
+       }
+
+       if (action == 'restart') {
+               output.info('Restarting ' + pkg.service_name + '...\\n');
+               output.verbose('[INIT] Restarting ' + pkg.service_name + '...\\n');
+               status_data.status = 'statusRestarting';
+               cfg.dnsmasq_sanity_check = false;
+               cfg.heartbeat_domain = null;
+               resolver('on_start');
+       }
+
+       if (action == 'start') {
+               output.info('Starting ' + pkg.service_name + '...\\n');
+               output.verbose('[INIT] Starting ' + pkg.service_name + '...\\n');
+               status_data.status = 'statusStarting';
+               cfg.dnsmasq_sanity_check = false;
+               cfg.heartbeat_domain = null;
+               resolver('on_start');
+       }
+
+       let final_status = status_data.status;
+       if (adb_file('test') && final_status != 'statusFail') {
+               status_data.message = '';
+               status_data.status = 'statusSuccess';
+               status_data.stats = pkg.service_name + ' is blocking ' + count_blocked_domains() + ' domains (with ' + cfg.dns + ')';
+               status_service('on_start_success');
+       } else {
+               status_data.status = 'statusFail';
+               push(status_data.errors, { code: 'errorOhSnap', info: '' });
+               status_service('on_start_failure');
+               resolver('revert');
+       }
+
+       // Compressed cache: create or remove
+       if (cfg.compressed_cache && !adb_file('test_gzip') && adb_file('test')) {
+               let start_time = time();
+               let step_title = 'Creating ' + cfg.dns + ' compressed cache';
+               output.info(step_title + ' ');
+               output.verbose('[PROC] ' + step_title + ' ');
+               status_data.message = get_text('statusProcessing') + ': ' + step_title;
+               if (adb_file('create_gzip'))
+                       output.okn();
+               else {
+                       output.failn();
+                       push(status_data.errors, { code: 'errorCreatingCompressedCache', info: '' });
+               }
+               let end_time = time();
+               let elapsed = end_time - start_time;
+               logger_debug('[PERF-DEBUG] ' + step_title + ' took ' + elapsed + 's');
+       } else {
+               adb_file('remove_gzip');
+       }
+
+       adb_config_cache('create');
+
+       return _build_procd_data();
+}
+
+// ── dl ──────────────────────────────────────────────────────────────
+
+function dl() {
+       return start(['download']);
+}
+
+// ── stop ────────────────────────────────────────────────────────────
+
+function stop() {
+       env.load_config();
+       if (adb_file('test')) {
+               output.info('Stopping ' + pkg.service_name + '... ');
+               output.verbose('[STOP] Stopping ' + pkg.service_name + '... ');
+               adb_file('create');
+               if (resolver('on_stop')) {
+                       system('ipset -q -! flush adb 2>/dev/null; ipset -q -! destroy adb 2>/dev/null');
+                       system('nft delete set inet fw4 adb4 2>/dev/null; nft delete set inet fw4 adb6 2>/dev/null');
+                       led_off(cfg.led);
+                       output.okn();
+                       status_data.status = 'statusStopped';
+                       status_data.message = '';
+               } else {
+                       output.failn();
+                       status_data.status = 'statusFail';
+                       push(status_data.errors, { code: 'errorStopping', info: '' });
+                       output.error(get_text('errorStopping'));
+               }
+       }
+       return { fw4_restart_needed: is_fw4_restart_needed() };
+}
+
+// ── Extra Commands ──────────────────────────────────────────────────
+
+function allow(string) {
+       env.load_config();
+       if (!adb_file('test')) {
+               output.print("No block-list ('" + dns_output.file + "') found.\\n");
+               return;
+       }
+       if (!string) {
+               output.print("Usage: /etc/init.d/" + pkg.name + " allow 'domain' ...\\n");
+               return;
+       }
+       if (cfg.dnsmasq_config_file_url) {
+               output.print("Allowing individual domains is not possible when using external dnsmasq config file.\\n");
+               return;
+       }
+
+       let resolver_name = split(cfg.dns, '.')[0];
+       output.info('Allowing domains and restarting ' + resolver_name + ' ');
+       output.verbose('[PROC] Allowing domains \\n');
+
+       for (let c in split('' + string, /\s+/)) {
+               if (!c) continue;
+               output.verbose('  ' + c + ' ');
+               let escaped = replace(c, /\./g, '\\.');
+               switch (split(cfg.dns, '.')[0]) {
+               case 'dnsmasq':
+                       sed_inplace(sprintf('\\:/\\(/%s\\|.%s\\):d', escaped, escaped), dns_output.file);
+                       break;
+               case 'smartdns':
+               case 'unbound':
+                       sed_inplace(sprintf('\\:\\("%s\\|.%s"\\):d', escaped, escaped), dns_output.file);
+                       break;
+               }
+               output.ok();
+               if (dns_output.allow_filter) {
+                       system(sprintf("echo %s | sed -E '%s' >> %s", shell_quote(c), dns_output.allow_filter, shell_quote(dns_output.file)));
+                       output.ok();
+               }
+               uci_list_add_if_new(pkg.name, 'config', 'allowed_domain', c);
+               output.ok();
+       }
+
+       if (cfg.compressed_cache) {
+               output.verbose('[PROC] Creating compressed cache ');
+               if (adb_file('create_gzip')) output.ok(); else output.fail();
+       }
+       output.verbose('[PROC] Committing changes to config ');
+       if (uci(pkg.name).commit(pkg.name)) {
+               let ad = uci(pkg.name).get(pkg.name, 'config', 'allowed_domain');
+               cfg.allowed_domain = ad ? replace((type(ad) == 'array') ? join(' ', ad) : '' + ad, /,/g, ' ') : null;
+               adb_config_cache('create');
+               status_data.stats = pkg.service_name + ' is blocking ' + count_blocked_domains() + ' domains (with ' + cfg.dns + ')';
+               output.ok();
+               if (cfg.dns == 'dnsmasq.ipset') {
+                       output.verbose('[PROC] Flushing adb ipset ');
+                       if (system('ipset -q -! flush adb 2>/dev/null') == 0) output.ok(); else output.fail();
+               }
+               if (cfg.dns == 'dnsmasq.nftset') {
+                       output.verbose('[PROC] Flushing adb nft sets ');
+                       system('nft flush set inet fw4 adb6 2>/dev/null');
+                       if (system('nft flush set inet fw4 adb4 2>/dev/null') == 0) output.ok(); else output.fail();
+               }
+               output.dns('Restarting ' + resolver_name + ' ');
+               if (service_restart(resolver_name)) output.ok(); else output.fail();
+       } else {
+               output.fail();
+       }
+       _update_ubus_status();
+       output.info('\\n');
+}
+
+function check(param) {
+       env.load_config();
+       if (!adb_file('test')) {
+               output.print("No block-list ('" + dns_output.file + "') found.\\n");
+               return;
+       }
+       if (!param) {
+               output.print("Usage: /etc/init.d/" + pkg.name + " check 'domain' ...\\n");
+               return;
+       }
+       for (let string in split('' + param, /\s+/)) {
+               if (!string) continue;
+               let c = grep_count(string, dns_output.file, '-c -E');
+               if (c > 0) {
+                       let word = (c == 1) ? '1 match' : c + ' matches';
+                       output.info("Found " + word + " for '" + string + "' in '" + dns_output.file + "'.\\n");
+                       output.verbose("[PROC] Found " + word + " for '" + string + "' in '" + dns_output.file + "'.\\n");
+                       if (c <= 20) {
+                               let matches = grep_output(string, dns_output.file);
+                               if (dns_output.parse_filter)
+                                       matches = cmd_output(sprintf("grep %s %s | sed '%s'", shell_quote(string), shell_quote(dns_output.file), dns_output.parse_filter));
+                               if (matches) output.print(matches + '\\n');
+                       }
+               } else {
+                       output.info("The '" + string + "' is not found in current block-list ('" + dns_output.file + "').\\n");
+                       output.verbose("[PROC] The '" + string + "' is not found in current block-list ('" + dns_output.file + "').\\n");
+               }
+       }
+}
+
+function check_tld() {
+       env.load_config();
+       if (!adb_file('test')) {
+               output.print("No block-list ('" + dns_output.file + "') found.\\n");
+               return;
+       }
+       let c = grep_count('\\.|server:', dns_output.file, '-cvE');
+       if (c > 0) {
+               let word = (c == 1) ? '1 match for TLD' : c + ' matches for TLDs';
+               output.info("Found " + word + " in '" + dns_output.file + "'.\\n");
+               output.verbose("[PROC] Found " + word + " in '" + dns_output.file + "'.\\n");
+               if (c <= 20) {
+                       let matches = grep_output('\\.|server:', dns_output.file, '-vE');
+                       if (dns_output.parse_filter)
+                               matches = cmd_output(sprintf("grep -vE '\\.|server:' %s | sed '%s'", shell_quote(dns_output.file), dns_output.parse_filter));
+                       if (matches) output.print(matches + '\\n');
+               }
+       } else {
+               output.info("No TLD was found in current block-list ('" + dns_output.file + "').\\n");
+               output.verbose("[PROC] No TLD was found in current block-list ('" + dns_output.file + "').\\n");
+       }
+}
+
+function check_leading_dot() {
+       env.load_config();
+       if (!adb_file('test')) {
+               output.print("No block-list ('" + dns_output.file + "') found.\\n");
+               return;
+       }
+       let search_string = '';
+       switch (split(cfg.dns, '.')[0]) {
+       case 'dnsmasq': search_string = '/\\.'; break;
+       case 'smartdns': search_string = '^\\.'; break;
+       case 'unbound': search_string = '"\\.'; break;
+       default: return;
+       }
+       let c = grep_count(search_string, dns_output.file);
+       if (c > 0) {
+               let word = (c == 1) ? '1 match for leading-dot domain' : c + ' matches for leading-dot domains';
+               output.info("Found " + word + " in '" + dns_output.file + "'.\\n");
+               output.verbose("[PROC] Found " + word + " in '" + dns_output.file + "'.\\n");
+               if (c <= 20) {
+                       let matches = grep_output(search_string, dns_output.file);
+                       if (dns_output.parse_filter)
+                               matches = cmd_output(sprintf("grep %s %s | sed '%s'", shell_quote(search_string), shell_quote(dns_output.file), dns_output.parse_filter));
+                       if (matches) output.print(matches + '\\n');
+               }
+       } else {
+               output.info("No leading-dot domain was found in current block-list ('" + dns_output.file + "').\\n");
+               output.verbose("[PROC] No leading-dot domain was found in current block-list ('" + dns_output.file + "').\\n");
+       }
+}
+
+function check_lists(param) {
+       env.load_config();
+       if (!param) {
+               output.print("Usage: /etc/init.d/" + pkg.name + " check_lists 'domain' ...\\n");
+               return;
+       }
+       uci(pkg.name).foreach(pkg.name, 'file_url', (s) => {
+               if (s.enabled == '0') return;
+               if ((s.action || 'block') != 'block') return;
+               let url = s.url;
+               let name = s.name || url;
+               if (!url) return;
+
+               output.info('Checking ' + name + ': ');
+               output.verbose('[ DL ] ' + name + ' ');
+
+               if (is_https_url(url) && !env.get_downloader().ssl_supported) {
+                       output.failn();
+                       return;
+               }
+               let r_tmp = trim(cmd_output('mktemp -q -t "' + pkg.name + '_tmp.XXXXXXXX"'));
+               if (!download(url, r_tmp) || !(stat(r_tmp)?.size > 0)) {
+                       output.failn();
+                       return;
+               }
+               output.verbose(sym.ok[1] + '\\n');
+               ensure_trailing_newline(r_tmp);
+
+               for (let string in split('' + param, /\s+/)) {
+                       if (!string) continue;
+                       let c = grep_count(string, r_tmp, '-c -E');
+                       if (c > 0) {
+                               let word = (c == 1) ? '1 match' : c + ' matches';
+                               output.info("found " + word + " for '" + string + "'.\\n");
+                               output.verbose("[PROC] Found " + word + " for '" + string + "' in '" + url + "'.\\n");
+                               let matches = grep_output(string, r_tmp);
+                               if (matches) output.print(matches + '\\n');
+                       } else {
+                               output.info("'" + string + "' not found.\\n");
+                               output.verbose("[PROC] The '" + string + "' is not found in '" + url + "'.\\n");
+                       }
+               }
+               unlink(r_tmp);
+       });
+}
+
+function killcache() {
+       env.load_config();
+       for (let mode in dns_modes) {
+               let dc = dns_modes[mode];
+               unlink(dc.cache);
+               unlink(cfg.compressed_cache_dir + '/' + dc.gzip);
+       }
+       resolver('cleanup');
+}
+
+function pause(timeout) {
+       env.load_config();
+       timeout = timeout || cfg.pause_timeout || '20';
+       stop();
+       output.info('Sleeping for ' + timeout + ' seconds... ');
+       output.verbose('[PROC] Sleeping for ' + timeout + ' seconds... ');
+       if (is_integer(timeout) && system('sleep ' + timeout) == 0)
+               output.okn();
+       else
+               output.failn();
+       let result = start(['on_pause']);
+       if (result) {
+               let conn = connect();
+               if (conn) {
+                       conn.call('service', 'set_data', { name: pkg.name, data: result });
+                       conn.disconnect();
+               }
+       }
+}
+
+function show_blocklist() {
+       env.load_config();
+       if (dns_output.file && dns_output.parse_filter)
+               system(sprintf("sed '%s' %s", dns_output.parse_filter, shell_quote(dns_output.file)));
+       else if (dns_output.file)
+               print(readfile(dns_output.file) || '');
+}
+
+function sizes() {
+       env.load_config();
+       uci(pkg.name).foreach(pkg.name, 'file_url', (s) => {
+               let size = get_url_filesize(s.url);
+               output.print((s.name || s.url) + (size ? ': ' + size : '') + ' ');
+               if (size) {
+                       uci(pkg.name).set(pkg.name, s['.name'], 'size', '' + size);
+                       output.okn();
+               } else {
+                       output.failn();
+               }
+       });
+       uci(pkg.name).save(pkg.name);
+       if (cfg.update_config_sizes && length(uci(pkg.name).changes(pkg.name) || []))
+               uci(pkg.name).commit(pkg.name);
+}
+
+// ── get_network_trigger_info (for service_triggers) ─────────────────
+
+function get_network_trigger_info() {
+       env.load_config();
+       let result = { procd_trigger_wan6: cfg.procd_trigger_wan6 };
+       return result;
+}
+
+// ── rpcd Data Functions ─────────────────────────────────────────────
+
+function get_init_status(name) {
+       name = name || pkg.name;
+       env.load('rpcd');
+
+       // Read pre-computed data from procd service (like PBR)
+       let conn = connect();
+       let ubus_data = conn ? conn.call('service', 'list', { name: pkg.name }) : null;
+       if (conn) conn.disconnect();
+       let svc_data = ubus_data?.[pkg.name]?.data;
+
+       // Gzip path (for live file-existence checks)
+       let gzip_path = svc_data?.outputGzip || '';
+       if (!gzip_path && cfg.compressed_cache_dir)
+               gzip_path = cfg.compressed_cache_dir + '/' + dns_output.gzip;
+
+       let result = {};
+       result[name] = {
+               version: pkg.version,
+               packageCompat: int(pkg.compat),
+
+               // Live-computed (cheap stat/uci checks)
+               enabled: service_enabled(pkg.name),
+               running: (stat(pkg.run_file)?.size > 0),
+               outputFileExists: (stat(svc_data?.outputFile || dns_output.file)?.size > 0) || false,
+               outputCacheExists: (stat(svc_data?.outputCache || dns_output.cache)?.size > 0) || false,
+               outputGzipExists: gzip_path ? (stat(gzip_path)?.size > 0) || false : false,
+
+               // From procd ubus data (pre-computed at start/dl time)
+               status: svc_data?.status || '',
+               message: svc_data?.message || '',
+               stats: svc_data?.stats || '',
+               entries: svc_data?.entries || 0,
+               dns: svc_data?.dns || cfg.dns,
+               outputFile: svc_data?.outputFile || dns_output.file,
+               outputCache: svc_data?.outputCache || dns_output.cache,
+               outputGzip: gzip_path,
+               force_dns_active: svc_data?.force_dns_active || false,
+               force_dns_ports: svc_data?.force_dns_ports || [],
+               errors: svc_data?.errors || [],
+               warnings: svc_data?.warnings || [],
+
+               // Platform support (from env.detect, runs once per rpcd lifetime)
+               platform: {
+                       ipset_installed: env.ipset_supported,
+                       nft_installed: env.nft_installed,
+                       dnsmasq_installed: env.dnsmasq_installed,
+                       dnsmasq_ipset_support: env.check_dnsmasq_ipset(),
+                       dnsmasq_nftset_support: env.check_dnsmasq_nftset(),
+                       smartdns_installed: env.smartdns_installed,
+                       smartdns_ipset_support: env.smartdns_installed && env.ipset_supported,
+                       smartdns_nftset_support: env.smartdns_installed && env.nft_installed,
+                       unbound_installed: env.unbound_installed,
+                       leds: lsdir('/sys/class/leds') || [],
+               },
+
+               // File URL sizes (from procd data, pre-computed at start time)
+               file_url: svc_data?.file_url || [],
+       };
+       return result;
+}
+
+function get_init_list(name) {
+       name = name || pkg.name;
+       let result = {};
+       let enabled_val = (uci(pkg.name).get(pkg.name, 'config', 'enabled') ?? '0');
+       result[name] = { enabled: (enabled_val == '1') };
+       return result;
+}
+
+function get_platform_support(name) {
+       name = name || pkg.name;
+       env.detect();
+       let result = {};
+       result[name] = {
+               ipset_installed: env.ipset_supported,
+               nft_installed: env.nft_installed,
+               dnsmasq_installed: env.dnsmasq_installed,
+               dnsmasq_ipset_support: env.check_dnsmasq_ipset(),
+               dnsmasq_nftset_support: env.check_dnsmasq_nftset(),
+               smartdns_installed: env.smartdns_installed,
+               smartdns_ipset_support: env.smartdns_installed && env.ipset_supported,
+               smartdns_nftset_support: env.smartdns_installed && env.nft_installed,
+               unbound_installed: env.unbound_installed,
+               leds: length(lsdir('/sys/class/leds') || []) > 0,
+       };
+       return result;
+}
+
+function get_file_url_filesizes(name) {
+       name = name || pkg.name;
+       env.load('rpcd');
+
+       let files = [];
+       uci(pkg.name).foreach(pkg.name, 'file_url', (s) => {
+               let size = s.size;
+               if (!size && s.url) size = get_url_filesize(s.url);
+               push(files, { name: s.name || s.url, url: s.url, size: size || '' });
+       });
+
+       let result = {};
+       result[name] = { file_url: files };
+       return result;
+}
+
+// ── Module Init & Export ────────────────────────────────────────────
+
+function set_script_name(name) {
+       state.script_name = name;
+}
+
+export default {
+       init: function() {}, // backward compat (rpcd plugin may still call this)
+       set_script_name,
+       pkg,
+
+       // Core lifecycle
+       env,
+       start,
+       stop,
+       status_service,
+
+       // Config
+       load_dl_command,
+       adb_config_update,
+       adb_config_cache,
+
+       // Extra commands
+       allow,
+       check,
+       check_tld,
+       check_leading_dot,
+       check_lists,
+       killcache,
+       pause,
+       show_blocklist,
+       sizes,
+
+       // rpcd data
+       get_init_status,
+       get_init_list,
+       get_platform_support,
+       get_file_url_filesizes,
+
+       // init script helpers
+       get_network_trigger_info,
+       dl,
+       emit_procd_shell,
+       process_file_url,
+};
+
diff --git a/net/adblock-fast/files/lib/adblock-fast/cli.uc b/net/adblock-fast/files/lib/adblock-fast/cli.uc
new file mode 100644 (file)
index 0000000..6c63145
--- /dev/null
@@ -0,0 +1,95 @@
+'use strict';
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Copyright 2023-2026 MOSSDeF, Stan Grishin (stangri@melmac.ca).
+//
+// CLI dispatcher for adblock-fast.
+// Called from init script:
+//   ucode -S -L /lib/adblock-fast /lib/adblock-fast/cli.uc -- <action> [args...]
+
+import adb from 'adblock-fast';
+
+let action = shift(ARGV);
+if (action == '--') action = shift(ARGV);
+
+switch (action) {
+case 'start':
+       let start_result = adb.start(ARGV);
+       if (start_result)
+               print(adb.emit_procd_shell(start_result));
+       break;
+
+case 'stop':
+       let stop_result = adb.stop();
+       if (stop_result)
+               print(adb.emit_procd_shell(stop_result));
+       break;
+
+case 'status':
+       adb.status_service(ARGV[0]);
+       break;
+
+case 'allow':
+       adb.allow(join(' ', ARGV));
+       break;
+
+case 'check':
+       adb.check(join(' ', ARGV));
+       break;
+
+case 'check_tld':
+       adb.check_tld();
+       break;
+
+case 'check_leading_dot':
+       adb.check_leading_dot();
+       break;
+
+case 'check_lists':
+       adb.check_lists(join(' ', ARGV));
+       break;
+
+case 'dl':
+       let dl_result = adb.dl();
+       if (dl_result)
+               print(adb.emit_procd_shell(dl_result));
+       break;
+
+case 'killcache':
+       adb.killcache();
+       break;
+
+case 'pause':
+       adb.pause(ARGV[0]);
+       break;
+
+case 'show_blocklist':
+       adb.show_blocklist();
+       break;
+
+case 'sizes':
+       adb.sizes();
+       break;
+
+case 'version':
+       print(adb.pkg.version + '\n');
+       break;
+
+case 'get_wan_interfaces':
+       let info = adb.get_network_trigger_info();
+       if (info)
+               print(sprintf('%J', info) + '\n');
+       break;
+
+case 'adb_config_update':
+       adb.adb_config_update(ARGV[0]);
+       break;
+
+case 'load_environment':
+       let env_ok = adb.env.load(ARGV[0], ARGV[1]);
+       exit(env_ok ? 0 : 1);
+       break;
+
+default:
+       warn('Unknown action: ' + (action || '(none)') + '\n');
+       exit(1);
+}
diff --git a/net/adblock-fast/tests/01_pipeline/01_all_dns_modes b/net/adblock-fast/tests/01_pipeline/01_all_dns_modes
new file mode 100644 (file)
index 0000000..66de03b
--- /dev/null
@@ -0,0 +1,193 @@
+Test that all 9 DNS modes produce valid output files containing
+domains from both the domains.txt and hosts.txt test data files.
+
+-- Testcase --
+import adb from 'adblock-fast';
+import { readfile, dirname, mkdir } from 'fs';
+let ti = adb._test_internals;
+
+let modes = [
+       'dnsmasq.servers',
+       'dnsmasq.conf',
+       'dnsmasq.ipset',
+       'dnsmasq.nftset',
+       'dnsmasq.addnhosts',
+       'smartdns.domainset',
+       'smartdns.ipset',
+       'smartdns.nftset',
+       'unbound.adb_list',
+];
+
+// Known-good domains that MUST appear in every output
+let must_have = [
+       'ad.doubleclick.test.example.com',
+       'tracker.analytics.test.example.com',
+       'common-shared-1.test.example.com',
+       'common-shared-10.test.example.com',
+       'adhost-zero-1.test.example.org',
+       'adhost-loopback-1.test.example.org',
+       'parent-dedup-1.test.example.com',
+];
+
+// Domains that MUST NOT appear (invalid entries or subdomain-deduped)
+let must_not_have = [
+       'localhost',
+       'nodot',
+       'child.parent-dedup-1.test.example.com',
+       'sub.child.parent-dedup-2.test.example.com',
+       'deep.sub.parent-dedup-3.test.example.com',
+];
+
+let results = [];
+
+for (let mode in modes) {
+       // Reset module state for each mode
+       ti.env._config_loaded = false;
+       ti.env._loaded = false;
+       ti.env.dnsmasq_features = '';
+       ti.env._detected = false;
+       ti.env.dnsmasq_ubus = null;
+       ti.status_data.errors = [];
+       ti.status_data.warnings = [];
+       ti.status_data.status = '';
+       ti.status_data.message = '';
+       ti.status_data.stats = '';
+
+       // Load config from mock UCI
+       adb.env.load_config();
+
+       // Override DNS mode via set_cfg (load() reassigns cfg, so direct ref is stale)
+       ti.set_cfg('dns', mode);
+       ti.set_cfg('enabled', true);
+       ti.set_cfg('dnsmasq_sanity_check', false);
+       ti.set_cfg('dnsmasq_validity_check', false);
+       ti.set_cfg('heartbeat_domain', null);
+       ti.set_cfg('config_update_enabled', false);
+       ti.set_cfg('update_config_sizes', false);
+       ti.env.dns_set_output_values(mode);
+
+       // Collect file_url sections
+       ti.append_urls();
+
+       // Ensure output directory exists
+       let out_file = ti.dns_output.file;
+       let out_dir = dirname(out_file);
+       mkdir(out_dir);
+
+       // Run the download and processing pipeline
+       let ok = ti.download_lists();
+
+       if (!ok) {
+               push(results, sprintf('%s: FAIL (download_lists returned false)', mode));
+               if (length(ti.status_data.errors))
+                       push(results, sprintf('  errors: %J', ti.status_data.errors));
+               continue;
+       }
+
+       // Read output
+       let content = readfile(out_file);
+       if (!content || !length(content)) {
+               push(results, sprintf('%s: FAIL (empty output file %s)', mode, out_file));
+               continue;
+       }
+
+       let lines = filter(split(content, '\n'), l => length(l) > 0);
+       let line_count = length(lines);
+
+       // Extract domains using the mode's parse_filter
+       let dm = ti.dns_modes[mode];
+       let domains = {};
+       let bad_format = 0;
+
+       for (let line in lines) {
+               let domain;
+               switch (mode) {
+               case 'dnsmasq.servers':
+                       let m1 = match(line, /^server=\/([^\/]+)\/$/);
+                       domain = m1 ? m1[1] : null;
+                       break;
+               case 'dnsmasq.conf':
+                       let m2 = match(line, /^local=\/([^\/]+)\/$/);
+                       domain = m2 ? m2[1] : null;
+                       break;
+               case 'dnsmasq.ipset':
+                       let m3 = match(line, /^ipset=\/([^\/]+)\/adb$/);
+                       domain = m3 ? m3[1] : null;
+                       break;
+               case 'dnsmasq.nftset':
+                       let m4 = match(line, /^nftset=\/([^\/]+)\/4#/);
+                       domain = m4 ? m4[1] : null;
+                       break;
+               case 'dnsmasq.addnhosts':
+                       let m5 = match(line, /^127\.0\.0\.1 (.+)$/);
+                       domain = m5 ? m5[1] : null;
+                       break;
+               case 'smartdns.domainset':
+               case 'smartdns.ipset':
+               case 'smartdns.nftset':
+                       domain = match(line, /^[a-zA-Z0-9._-]+$/) ? line : null;
+                       break;
+               case 'unbound.adb_list':
+                       let m6 = match(line, /^local-zone: "([^"]+)\." always_nxdomain$/);
+                       domain = m6 ? m6[1] : null;
+                       if (!domain && line == 'server:') domain = '__header__';
+                       break;
+               }
+               if (domain && domain != '__header__')
+                       domains[domain] = true;
+               else if (!domain)
+                       bad_format++;
+       }
+
+       let domain_count = length(keys(domains));
+
+       // Check must_have domains
+       let missing = [];
+       for (let d in must_have) {
+               if (!domains[d])
+                       push(missing, d);
+       }
+
+       // Check must_not_have domains
+       let unwanted = [];
+       for (let d in must_not_have) {
+               if (domains[d])
+                       push(unwanted, d);
+       }
+
+       // dnsmasq.addnhosts doesn't do subdomain dedup (not in needs_optimization list)
+       let skip_dedup = (mode == 'dnsmasq.addnhosts');
+       if (skip_dedup) {
+               unwanted = filter(unwanted, d =>
+                       d != 'child.parent-dedup-1.test.example.com' &&
+                       d != 'sub.child.parent-dedup-2.test.example.com' &&
+                       d != 'deep.sub.parent-dedup-3.test.example.com');
+       }
+
+       if (length(missing) == 0 && length(unwanted) == 0 && bad_format == 0 && domain_count > 100)
+               push(results, sprintf('%s: PASS (%d domains)', mode, domain_count));
+       else {
+               let detail = sprintf('%s: FAIL (%d domains, %d bad_format', mode, domain_count, bad_format);
+               if (length(missing))
+                       detail += sprintf(', missing: %J', missing);
+               if (length(unwanted))
+                       detail += sprintf(', unwanted: %J', unwanted);
+               detail += ')';
+               push(results, detail);
+       }
+}
+
+print(join('\n', results) + '\n');
+-- End --
+
+-- Expect stdout --
+dnsmasq.servers: PASS (162 domains)
+dnsmasq.conf: PASS (162 domains)
+dnsmasq.ipset: PASS (162 domains)
+dnsmasq.nftset: PASS (162 domains)
+dnsmasq.addnhosts: PASS (165 domains)
+smartdns.domainset: PASS (162 domains)
+smartdns.ipset: PASS (162 domains)
+smartdns.nftset: PASS (162 domains)
+unbound.adb_list: PASS (162 domains)
+-- End --
diff --git a/net/adblock-fast/tests/01_pipeline/02_input_format_detection b/net/adblock-fast/tests/01_pipeline/02_input_format_detection
new file mode 100644 (file)
index 0000000..a210c72
--- /dev/null
@@ -0,0 +1,42 @@
+Test that detect_file_type() correctly identifies all supported list formats.
+
+-- Testcase --
+import adb from 'adblock-fast';
+import { writefile, mkdir } from 'fs';
+let ti = adb._test_internals;
+
+adb.env.load_config();
+
+let test_dir = '' + TESTDIR + '/fmt_test';
+mkdir(test_dir);
+
+let tests = [
+       ['domains', 'example.com\nad.tracker.net\nmalware.bad.org\n'],
+       ['hosts', '0.0.0.0 example.com\n127.0.0.1 tracker.net\n0.0.0.0 malware.org\n'],
+       ['adblockplus', '[Adblock Plus]\n||example.com^\n||tracker.net^\n'],
+       ['dnsmasq', 'server=/example.com/\nserver=/tracker.net/\n'],
+];
+
+let results = [];
+
+for (let t in tests) {
+       let name = t[0];
+       let content = t[1];
+       let path = test_dir + '/' + name + '.txt';
+       writefile(path, content);
+       let detected = ti.detect_file_type(path);
+       if (detected == name)
+               push(results, sprintf('%s: PASS', name));
+       else
+               push(results, sprintf('%s: FAIL (detected as %s)', name, detected));
+}
+
+print(join('\n', results) + '\n');
+-- End --
+
+-- Expect stdout --
+domains: PASS
+hosts: PASS
+adblockplus: PASS
+dnsmasq: PASS
+-- End --
diff --git a/net/adblock-fast/tests/01_pipeline/03_subdomain_dedup b/net/adblock-fast/tests/01_pipeline/03_subdomain_dedup
new file mode 100644 (file)
index 0000000..b0fde87
--- /dev/null
@@ -0,0 +1,58 @@
+Test that subdomain dedup removes child domains when parent exists.
+Parent domains are in domains.txt, children are in hosts.txt.
+
+-- Testcase --
+import adb from 'adblock-fast';
+import { readfile } from 'fs';
+let ti = adb._test_internals;
+
+adb.env.load_config();
+ti.set_cfg('dns', 'dnsmasq.servers');
+ti.set_cfg('dnsmasq_sanity_check', false);
+ti.set_cfg('dnsmasq_validity_check', false);
+ti.set_cfg('heartbeat_domain', null);
+ti.set_cfg('config_update_enabled', false);
+ti.set_cfg('update_config_sizes', false);
+ti.env.dns_set_output_values('dnsmasq.servers');
+ti.append_urls();
+
+let ok = ti.download_lists();
+if (!ok) {
+       print('download_lists failed\n');
+} else {
+       let content = readfile(ti.dns_output.file) || '';
+
+       // Parents must exist
+       let parents = [
+               'parent-dedup-1.test.example.com',
+               'parent-dedup-2.test.example.com',
+               'parent-dedup-3.test.example.com',
+       ];
+       // Children must be removed
+       let children = [
+               'child.parent-dedup-1.test.example.com',
+               'sub.child.parent-dedup-2.test.example.com',
+               'deep.sub.parent-dedup-3.test.example.com',
+       ];
+
+       let results = [];
+       for (let p in parents) {
+               let found = index(content, 'server=/' + p + '/') >= 0;
+               push(results, sprintf('parent %s: %s', p, found ? 'PRESENT' : 'MISSING'));
+       }
+       for (let c in children) {
+               let found = index(content, 'server=/' + c + '/') >= 0;
+               push(results, sprintf('child %s: %s', c, found ? 'PRESENT (BAD)' : 'REMOVED'));
+       }
+       print(join('\n', results) + '\n');
+}
+-- End --
+
+-- Expect stdout --
+parent parent-dedup-1.test.example.com: PRESENT
+parent parent-dedup-2.test.example.com: PRESENT
+parent parent-dedup-3.test.example.com: PRESENT
+child child.parent-dedup-1.test.example.com: REMOVED
+child sub.child.parent-dedup-2.test.example.com: REMOVED
+child deep.sub.parent-dedup-3.test.example.com: REMOVED
+-- End --
diff --git a/net/adblock-fast/tests/01_pipeline/04_allowed_domains b/net/adblock-fast/tests/01_pipeline/04_allowed_domains
new file mode 100644 (file)
index 0000000..a54d03f
--- /dev/null
@@ -0,0 +1,55 @@
+Test that allowed_domain config option removes domains and subdomains from output.
+
+-- Testcase --
+import adb from 'adblock-fast';
+import { readfile } from 'fs';
+let ti = adb._test_internals;
+
+adb.env.load_config();
+ti.set_cfg('dns', 'dnsmasq.servers');
+ti.set_cfg('dnsmasq_sanity_check', false);
+ti.set_cfg('dnsmasq_validity_check', false);
+ti.set_cfg('heartbeat_domain', null);
+ti.set_cfg('config_update_enabled', false);
+ti.set_cfg('update_config_sizes', false);
+ti.set_cfg('allowed_domain', 'ad.doubleclick.test.example.com tracker.analytics.test.example.com');
+ti.env.dns_set_output_values('dnsmasq.servers');
+ti.append_urls();
+
+let ok = ti.download_lists();
+if (!ok) {
+       print('download_lists failed\n');
+} else {
+       let content = readfile(ti.dns_output.file) || '';
+
+       // These should be removed by allowed_domain
+       let allowed = [
+               'ad.doubleclick.test.example.com',
+               'tracker.analytics.test.example.com',
+       ];
+       // This should still be present
+       let blocked = [
+               'pixel.tracking.test.example.com',
+               'beacon.metrics.test.example.com',
+       ];
+
+       let results = [];
+       for (let a in allowed) {
+               // Check for block format "server=/domain/\n" — not allow format "server=/domain/#\n"
+               let found = index(content, 'server=/' + a + '/\n') >= 0;
+               push(results, sprintf('allowed %s: %s', a, found ? 'STILL PRESENT (BAD)' : 'REMOVED'));
+       }
+       for (let b in blocked) {
+               let found = index(content, 'server=/' + b + '/\n') >= 0;
+               push(results, sprintf('blocked %s: %s', b, found ? 'PRESENT' : 'MISSING'));
+       }
+       print(join('\n', results) + '\n');
+}
+-- End --
+
+-- Expect stdout --
+allowed ad.doubleclick.test.example.com: REMOVED
+allowed tracker.analytics.test.example.com: REMOVED
+blocked pixel.tracking.test.example.com: PRESENT
+blocked beacon.metrics.test.example.com: PRESENT
+-- End --
diff --git a/net/adblock-fast/tests/01_pipeline/05_canary_domains b/net/adblock-fast/tests/01_pipeline/05_canary_domains
new file mode 100644 (file)
index 0000000..f1944d4
--- /dev/null
@@ -0,0 +1,45 @@
+Test that canary domains are injected when enabled.
+
+-- Testcase --
+import adb from 'adblock-fast';
+import { readfile } from 'fs';
+let ti = adb._test_internals;
+
+adb.env.load_config();
+ti.set_cfg('dns', 'dnsmasq.servers');
+ti.set_cfg('dnsmasq_sanity_check', false);
+ti.set_cfg('dnsmasq_validity_check', false);
+ti.set_cfg('heartbeat_domain', null);
+ti.set_cfg('config_update_enabled', false);
+ti.set_cfg('update_config_sizes', false);
+ti.set_cfg('canary_domains_icloud', true);
+ti.set_cfg('canary_domains_mozilla', true);
+ti.env.dns_set_output_values('dnsmasq.servers');
+ti.append_urls();
+
+let ok = ti.download_lists();
+if (!ok) {
+       print('download_lists failed\n');
+} else {
+       let content = readfile(ti.dns_output.file) || '';
+
+       let canary_domains = [
+               'mask.icloud.com',
+               'mask-h2.icloud.com',
+               'use-application-dns.net',
+       ];
+
+       let results = [];
+       for (let d in canary_domains) {
+               let found = index(content, 'server=/' + d + '/') >= 0;
+               push(results, sprintf('canary %s: %s', d, found ? 'PRESENT' : 'MISSING'));
+       }
+       print(join('\n', results) + '\n');
+}
+-- End --
+
+-- Expect stdout --
+canary mask.icloud.com: PRESENT
+canary mask-h2.icloud.com: PRESENT
+canary use-application-dns.net: PRESENT
+-- End --
diff --git a/net/adblock-fast/tests/01_pipeline/06_servers_mode_allow b/net/adblock-fast/tests/01_pipeline/06_servers_mode_allow
new file mode 100644 (file)
index 0000000..561781a
--- /dev/null
@@ -0,0 +1,57 @@
+Test that dnsmasq.servers mode prepends explicit allow entries (server=/domain/#)
+when allowed_domain is set.
+
+-- Testcase --
+import adb from 'adblock-fast';
+import { readfile } from 'fs';
+let ti = adb._test_internals;
+
+adb.env.load_config();
+ti.set_cfg('dns', 'dnsmasq.servers');
+ti.set_cfg('dnsmasq_sanity_check', false);
+ti.set_cfg('dnsmasq_validity_check', false);
+ti.set_cfg('heartbeat_domain', null);
+ti.set_cfg('config_update_enabled', false);
+ti.set_cfg('update_config_sizes', false);
+ti.set_cfg('allowed_domain', 'safe.example.com also-safe.example.com');
+ti.env.dns_set_output_values('dnsmasq.servers');
+ti.append_urls();
+
+let ok = ti.download_lists();
+if (!ok) {
+       print('download_lists failed\n');
+} else {
+       let content = readfile(ti.dns_output.file) || '';
+       let lines = filter(split(content, '\n'), l => length(l) > 0);
+
+       // In servers mode with allowed_domain, server=/domain/# entries are prepended
+       let allow_entries = filter(lines, l => match(l, /\/#$/));
+       let block_entries = filter(lines, l => match(l, /\/$/));
+
+       let results = [];
+       push(results, sprintf('allow_entries: %d', length(allow_entries)));
+       push(results, sprintf('block_entries: %d', length(block_entries)));
+
+       // Check specific allow entries
+       let has_safe = index(content, 'server=/safe.example.com/#') >= 0;
+       let has_also = index(content, 'server=/also-safe.example.com/#') >= 0;
+       push(results, sprintf('safe.example.com allow: %s', has_safe ? 'PRESENT' : 'MISSING'));
+       push(results, sprintf('also-safe.example.com allow: %s', has_also ? 'PRESENT' : 'MISSING'));
+
+       // Allow entries should be at the top
+       if (length(allow_entries) > 0 && length(lines) > 0) {
+               let first_is_allow = match(lines[0], /\/#$/);
+               push(results, sprintf('allow_entries_at_top: %s', first_is_allow ? 'YES' : 'NO'));
+       }
+
+       print(join('\n', results) + '\n');
+}
+-- End --
+
+-- Expect stdout --
+allow_entries: 2
+block_entries: 162
+safe.example.com allow: PRESENT
+also-safe.example.com allow: PRESENT
+allow_entries_at_top: YES
+-- End --
diff --git a/net/adblock-fast/tests/01_pipeline/07_ipv6_addnhosts b/net/adblock-fast/tests/01_pipeline/07_ipv6_addnhosts
new file mode 100644 (file)
index 0000000..df36d90
--- /dev/null
@@ -0,0 +1,50 @@
+Test that dnsmasq.addnhosts with ipv6_enabled produces both
+127.0.0.1 and :: entries (dual-stack).
+
+-- Testcase --
+import adb from 'adblock-fast';
+import { readfile } from 'fs';
+let ti = adb._test_internals;
+
+adb.env.load_config();
+ti.set_cfg('dns', 'dnsmasq.addnhosts');
+ti.set_cfg('ipv6_enabled', true);
+ti.set_cfg('dnsmasq_sanity_check', false);
+ti.set_cfg('dnsmasq_validity_check', false);
+ti.set_cfg('heartbeat_domain', null);
+ti.set_cfg('config_update_enabled', false);
+ti.set_cfg('update_config_sizes', false);
+ti.env.dns_set_output_values('dnsmasq.addnhosts');
+ti.append_urls();
+
+let ok = ti.download_lists();
+if (!ok) {
+       print('download_lists failed\n');
+} else {
+       let content = readfile(ti.dns_output.file) || '';
+       let lines = filter(split(content, '\n'), l => length(l) > 0);
+
+       let ipv4_lines = filter(lines, l => match(l, /^127\.0\.0\.1 /));
+       let ipv6_lines = filter(lines, l => match(l, /^:: /));
+
+       let results = [];
+       push(results, sprintf('ipv4_count: %d', length(ipv4_lines)));
+       push(results, sprintf('ipv6_count: %d', length(ipv6_lines)));
+       push(results, sprintf('counts_match: %s', length(ipv4_lines) == length(ipv6_lines) ? 'YES' : 'NO'));
+
+       // Spot-check a specific domain appears in both
+       let test_domain = 'ad.doubleclick.test.example.com';
+       let has_ipv4 = index(content, '127.0.0.1 ' + test_domain) >= 0;
+       let has_ipv6 = index(content, ':: ' + test_domain) >= 0;
+       push(results, sprintf('dual_stack_%s: %s', test_domain, (has_ipv4 && has_ipv6) ? 'YES' : 'NO'));
+
+       print(join('\n', results) + '\n');
+}
+-- End --
+
+-- Expect stdout --
+ipv4_count: 165
+ipv6_count: 165
+counts_match: YES
+dual_stack_ad.doubleclick.test.example.com: YES
+-- End --
diff --git a/net/adblock-fast/tests/01_pipeline/08_ipv6_nftset b/net/adblock-fast/tests/01_pipeline/08_ipv6_nftset
new file mode 100644 (file)
index 0000000..1c36d17
--- /dev/null
@@ -0,0 +1,54 @@
+Test that dnsmasq.nftset with ipv6_enabled includes IPv6 set reference.
+
+-- Testcase --
+import adb from 'adblock-fast';
+import { readfile } from 'fs';
+let ti = adb._test_internals;
+
+adb.env.load_config();
+ti.set_cfg('dns', 'dnsmasq.nftset');
+ti.set_cfg('ipv6_enabled', true);
+ti.set_cfg('dnsmasq_sanity_check', false);
+ti.set_cfg('dnsmasq_validity_check', false);
+ti.set_cfg('heartbeat_domain', null);
+ti.set_cfg('config_update_enabled', false);
+ti.set_cfg('update_config_sizes', false);
+ti.env.dns_set_output_values('dnsmasq.nftset');
+ti.append_urls();
+
+let ok = ti.download_lists();
+if (!ok) {
+       print('download_lists failed\n');
+} else {
+       let content = readfile(ti.dns_output.file) || '';
+       let lines = filter(split(content, '\n'), l => length(l) > 0);
+
+       // With IPv6 enabled, nftset format should include ,6#inet#fw4#adb6
+       let ipv6_lines = filter(lines, l => match(l, /6#inet#fw4#adb6/));
+       let ipv4_only = filter(lines, l => match(l, /4#inet#fw4#adb4/) && !match(l, /6#inet#fw4#adb6/));
+
+       let results = [];
+       push(results, sprintf('total_lines: %d', length(lines)));
+       push(results, sprintf('with_ipv6_set: %d', length(ipv6_lines)));
+       push(results, sprintf('ipv4_only: %d', length(ipv4_only)));
+
+       // All lines should have both IPv4 and IPv6 set references
+       push(results, sprintf('all_dual_stack: %s', (length(ipv6_lines) == length(lines)) ? 'YES' : 'NO'));
+
+       // Spot-check format
+       let test_domain = 'ad.doubleclick.test.example.com';
+       let expected = 'nftset=/' + test_domain + '/4#inet#fw4#adb4,6#inet#fw4#adb6';
+       let has_entry = index(content, expected) >= 0;
+       push(results, sprintf('correct_format: %s', has_entry ? 'YES' : 'NO'));
+
+       print(join('\n', results) + '\n');
+}
+-- End --
+
+-- Expect stdout --
+total_lines: 162
+with_ipv6_set: 162
+ipv4_only: 0
+all_dual_stack: YES
+correct_format: YES
+-- End --
diff --git a/net/adblock-fast/tests/01_pipeline/09_unbound_header b/net/adblock-fast/tests/01_pipeline/09_unbound_header
new file mode 100644 (file)
index 0000000..dc73b1f
--- /dev/null
@@ -0,0 +1,48 @@
+Test that unbound.adb_list mode prepends "server:" header line.
+
+-- Testcase --
+import adb from 'adblock-fast';
+import { readfile } from 'fs';
+let ti = adb._test_internals;
+
+adb.env.load_config();
+ti.set_cfg('dns', 'unbound.adb_list');
+ti.set_cfg('dnsmasq_sanity_check', false);
+ti.set_cfg('dnsmasq_validity_check', false);
+ti.set_cfg('heartbeat_domain', null);
+ti.set_cfg('config_update_enabled', false);
+ti.set_cfg('update_config_sizes', false);
+ti.env.dns_set_output_values('unbound.adb_list');
+ti.append_urls();
+
+let ok = ti.download_lists();
+if (!ok) {
+       print('download_lists failed\n');
+} else {
+       let content = readfile(ti.dns_output.file) || '';
+       let lines = filter(split(content, '\n'), l => length(l) > 0);
+
+       let results = [];
+
+       // First line should be "server:"
+       push(results, sprintf('first_line: %s', lines[0]));
+
+       // Rest should be local-zone entries
+       let lz_lines = filter(lines, l => match(l, /^local-zone: /));
+       push(results, sprintf('local_zone_entries: %d', length(lz_lines)));
+
+       // Spot-check format
+       let test_domain = 'ad.doubleclick.test.example.com';
+       let expected = 'local-zone: "' + test_domain + '." always_nxdomain';
+       let has_entry = index(content, expected) >= 0;
+       push(results, sprintf('correct_format: %s', has_entry ? 'YES' : 'NO'));
+
+       print(join('\n', results) + '\n');
+}
+-- End --
+
+-- Expect stdout --
+first_line: server:
+local_zone_entries: 162
+correct_format: YES
+-- End --
diff --git a/net/adblock-fast/tests/02_config/01_blocked_domain_injection b/net/adblock-fast/tests/02_config/01_blocked_domain_injection
new file mode 100644 (file)
index 0000000..0caf3e0
--- /dev/null
@@ -0,0 +1,41 @@
+Test that cfg.blocked_domain entries are added to the output.
+
+-- Testcase --
+import adb from 'adblock-fast';
+import { readfile } from 'fs';
+let ti = adb._test_internals;
+
+adb.env.load_config();
+ti.set_cfg('dns', 'dnsmasq.servers');
+ti.set_cfg('dnsmasq_sanity_check', false);
+ti.set_cfg('dnsmasq_validity_check', false);
+ti.set_cfg('heartbeat_domain', null);
+ti.set_cfg('config_update_enabled', false);
+ti.set_cfg('update_config_sizes', false);
+ti.set_cfg('blocked_domain', 'custom-block-1.example.net custom-block-2.example.net');
+ti.env.dns_set_output_values('dnsmasq.servers');
+ti.append_urls();
+
+let ok = ti.download_lists();
+if (!ok) {
+       print('download_lists failed\n');
+} else {
+       let content = readfile(ti.dns_output.file) || '';
+
+       let customs = [
+               'custom-block-1.example.net',
+               'custom-block-2.example.net',
+       ];
+       let results = [];
+       for (let d in customs) {
+               let found = index(content, 'server=/' + d + '/') >= 0;
+               push(results, sprintf('%s: %s', d, found ? 'PRESENT' : 'MISSING'));
+       }
+       print(join('\n', results) + '\n');
+}
+-- End --
+
+-- Expect stdout --
+custom-block-1.example.net: PRESENT
+custom-block-2.example.net: PRESENT
+-- End --
diff --git a/net/adblock-fast/tests/03_functional/01_check_domain b/net/adblock-fast/tests/03_functional/01_check_domain
new file mode 100644 (file)
index 0000000..9b013eb
--- /dev/null
@@ -0,0 +1,33 @@
+Test that check() correctly identifies blocked and unblocked domains.
+check() output goes to stderr via output.info/output.print when is_tty=true.
+
+-- Testcase --
+import adb from 'adblock-fast';
+let ti = adb._test_internals;
+
+// Build the blocklist
+adb.env.load_config();
+ti.set_cfg('dns', 'dnsmasq.servers');
+ti.set_cfg('dnsmasq_sanity_check', false);
+ti.set_cfg('dnsmasq_validity_check', false);
+ti.set_cfg('heartbeat_domain', null);
+ti.set_cfg('config_update_enabled', false);
+ti.set_cfg('update_config_sizes', false);
+ti.env.dns_set_output_values('dnsmasq.servers');
+ti.append_urls();
+ti.download_lists();
+
+// Enable tty mode so output goes to stderr (captured by test framework)
+// Use verbosity=2 to get verbose output (which includes [PROC] prefix)
+ti.state.is_tty = true;
+ti.set_cfg('verbosity', 2);
+
+// Now test check()
+adb.check('ad.doubleclick.test.example.com this-domain-not-in-list.test.example.com');
+-- End --
+
+-- Expect stderr --
+[PROC] Found 1 match for 'ad.doubleclick.test.example.com' in 'TESTDIR/var_run/adblock-fast/dnsmasq.servers'.
+ad.doubleclick.test.example.com
+[PROC] The 'this-domain-not-in-list.test.example.com' is not found in current block-list ('TESTDIR/var_run/adblock-fast/dnsmasq.servers').
+-- End --
diff --git a/net/adblock-fast/tests/03_functional/02_show_blocklist b/net/adblock-fast/tests/03_functional/02_show_blocklist
new file mode 100644 (file)
index 0000000..52f877a
--- /dev/null
@@ -0,0 +1,53 @@
+Test that show_blocklist() outputs parsed domains to stdout.
+
+-- Testcase --
+import adb from 'adblock-fast';
+import { popen } from 'fs';
+let ti = adb._test_internals;
+
+// Build the blocklist
+adb.env.load_config();
+ti.set_cfg('dns', 'dnsmasq.servers');
+ti.set_cfg('dnsmasq_sanity_check', false);
+ti.set_cfg('dnsmasq_validity_check', false);
+ti.set_cfg('heartbeat_domain', null);
+ti.set_cfg('config_update_enabled', false);
+ti.set_cfg('update_config_sizes', false);
+ti.env.dns_set_output_values('dnsmasq.servers');
+ti.append_urls();
+ti.download_lists();
+
+// show_blocklist() uses system() to run sed parse_filter on the output file,
+// which outputs plain domains to stdout. Count lines and check a few domains.
+let cmd = sprintf("sed '%s' %s", ti.dns_output.parse_filter, ti.dns_output.file);
+let p = popen(cmd, 'r');
+let out = p ? (p.read('all') || '') : '';
+if (p) p.close();
+
+let domains = filter(split(out, '\n'), l => length(l) > 0);
+let count = length(domains);
+
+let must_have = [
+       'ad.doubleclick.test.example.com',
+       'common-shared-1.test.example.com',
+       'adhost-zero-1.test.example.org',
+];
+
+let domain_set = {};
+for (let d in domains) domain_set[d] = true;
+
+let results = [];
+push(results, sprintf('domain_count: %d', count));
+for (let d in must_have) {
+       push(results, sprintf('%s: %s', d, domain_set[d] ? 'PRESENT' : 'MISSING'));
+}
+
+print(join('\n', results) + '\n');
+-- End --
+
+-- Expect stdout --
+domain_count: 162
+ad.doubleclick.test.example.com: PRESENT
+common-shared-1.test.example.com: PRESENT
+adhost-zero-1.test.example.org: PRESENT
+-- End --
diff --git a/net/adblock-fast/tests/data/adblockplus.txt b/net/adblock-fast/tests/data/adblockplus.txt
new file mode 100644 (file)
index 0000000..f2203da
--- /dev/null
@@ -0,0 +1,26 @@
+[Adblock Plus 2.0]
+! Title: Test ABP blocklist
+! Last modified: 2024-01-01
+! Homepage: https://test.example.com
+
+||abp-tracker-1.test.example.com^
+||abp-tracker-2.test.example.com^
+||abp-tracker-3.test.example.com^
+||abp-tracker-4.test.example.com^
+||abp-tracker-5.test.example.com^
+||abp-tracker-6.test.example.com^
+||abp-tracker-7.test.example.com^
+||abp-tracker-8.test.example.com^
+||abp-tracker-9.test.example.com^
+||abp-tracker-10.test.example.com^
+||abp-tracker-11.test.example.com^$third-party
+||abp-tracker-12.test.example.com^$third-party
+||abp-tracker-13.test.example.com^
+||abp-tracker-14.test.example.com^
+||abp-tracker-15.test.example.com^
+! Comment between entries
+||abp-tracker-16.test.example.com^
+||abp-tracker-17.test.example.com^
+||abp-tracker-18.test.example.com^
+||abp-tracker-19.test.example.com^
+||abp-tracker-20.test.example.com^
diff --git a/net/adblock-fast/tests/data/allowed.txt b/net/adblock-fast/tests/data/allowed.txt
new file mode 100644 (file)
index 0000000..de12cea
--- /dev/null
@@ -0,0 +1,6 @@
+# Allowed domains list (hosts format, used with action=allow)
+0.0.0.0 adhost-zero-1.test.example.org
+0.0.0.0 adhost-zero-2.test.example.org
+0.0.0.0 adhost-zero-3.test.example.org
+0.0.0.0 common-shared-1.test.example.com
+0.0.0.0 common-shared-2.test.example.com
diff --git a/net/adblock-fast/tests/data/dnsmasq_servers.txt b/net/adblock-fast/tests/data/dnsmasq_servers.txt
new file mode 100644 (file)
index 0000000..26fb4e8
--- /dev/null
@@ -0,0 +1,10 @@
+server=/dnsmasq-input-1.test.example.com/
+server=/dnsmasq-input-2.test.example.com/
+server=/dnsmasq-input-3.test.example.com/
+server=/dnsmasq-input-4.test.example.com/
+server=/dnsmasq-input-5.test.example.com/
+server=/dnsmasq-input-6.test.example.com/
+server=/dnsmasq-input-7.test.example.com/
+server=/dnsmasq-input-8.test.example.com/
+server=/dnsmasq-input-9.test.example.com/
+server=/dnsmasq-input-10.test.example.com/
diff --git a/net/adblock-fast/tests/data/domains.txt b/net/adblock-fast/tests/data/domains.txt
new file mode 100644 (file)
index 0000000..27bef17
--- /dev/null
@@ -0,0 +1,120 @@
+# Test domain blocklist for adblock-fast functional tests
+# Lines starting with # are comments and should be filtered out
+
+# -- Valid domains (85 unique) --
+ad.doubleclick.test.example.com
+ads.bigadnetwork.test.example.com
+tracker.analytics.test.example.com
+pixel.tracking.test.example.com
+beacon.metrics.test.example.com
+telemetry.data.test.example.com
+stats.counter.test.example.com
+banner.display.test.example.com
+popup.overlay.test.example.com
+interstitial.fullpage.test.example.com
+video.preroll.test.example.com
+native.sponsored.test.example.com
+widget.recommendation.test.example.com
+feed.promoted.test.example.com
+sidebar.adunit.test.example.com
+footer.adzone.test.example.com
+header.leaderboard.test.example.com
+skyscraper.tower.test.example.com
+rectangle.medium.test.example.com
+billboard.jumbo.test.example.com
+expandable.rich.test.example.com
+floating.sticky.test.example.com
+adhesion.anchor.test.example.com
+pushdown.slide.test.example.com
+catfish.bottom.test.example.com
+curtain.takeover.test.example.com
+skin.wrap.test.example.com
+roadblock.wall.test.example.com
+splash.welcome.test.example.com
+exit.intent.test.example.com
+retarget.remarket.test.example.com
+lookalike.audience.test.example.com
+segment.profile.test.example.com
+cookie.sync.test.example.com
+fingerprint.device.test.example.com
+supercookie.persist.test.example.com
+evercookie.track.test.example.com
+canvas.fp.test.example.com
+webgl.hash.test.example.com
+audio.context.test.example.com
+font.enum.test.example.com
+plugin.detect.test.example.com
+screen.res.test.example.com
+timezone.offset.test.example.com
+language.pref.test.example.com
+dnt.ignore.test.example.com
+referer.leak.test.example.com
+click.redirect.test.example.com
+impression.log.test.example.com
+conversion.pixel.test.example.com
+attribution.track.test.example.com
+viewability.measure.test.example.com
+brand.safety.test.example.com
+fraud.detect.test.example.com
+bot.filter.test.example.com
+programmatic.bid.test.example.com
+realtime.auction.test.example.com
+demand.side.test.example.com
+supply.platform.test.example.com
+exchange.market.test.example.com
+mediation.waterfall.test.example.com
+prebid.header.test.example.com
+openrtb.proto.test.example.com
+vast.vpaid.test.example.com
+mraid.sdk.test.example.com
+gdpr.consent.test.example.com
+ccpa.optout.test.example.com
+tcf.vendor.test.example.com
+cmp.dialog.test.example.com
+ab.testing.test.example.com
+multivariate.experiment.test.example.com
+heatmap.session.test.example.com
+scroll.depth.test.example.com
+funnel.analysis.test.example.com
+cohort.study.test.example.com
+survey.feedback.test.example.com
+nps.score.test.example.com
+crm.integration.test.example.com
+cdp.unify.test.example.com
+dmp.segment.test.example.com
+tag.manager.test.example.com
+container.script.test.example.com
+
+# -- Domains shared with hosts.txt (10 overlapping) --
+common-shared-1.test.example.com
+common-shared-2.test.example.com
+common-shared-3.test.example.com
+common-shared-4.test.example.com
+common-shared-5.test.example.com
+common-shared-6.test.example.com
+common-shared-7.test.example.com
+common-shared-8.test.example.com
+common-shared-9.test.example.com
+common-shared-10.test.example.com
+
+# -- Subdomain dedup test pairs (parent in this file) --
+parent-dedup-1.test.example.com
+parent-dedup-2.test.example.com
+parent-dedup-3.test.example.com
+
+# -- Invalid entries (should be filtered out) --
+# Comment only (filtered by /^#/d)
+192.168.1.1
+10.0.0.1
+127.0.0.1
+0.0.0.0
+255.255.255.255
+-starts-with-dash.test.example.com
+.starts-with-dot.test.example.com
+ends-with-dot.test.example.com.
+has..double.dots.test.example.com
+has spaces in it.test.example.com
+has@symbol.test.example.com
+has!bang.test.example.com
+nodot
+just a random sentence
diff --git a/net/adblock-fast/tests/data/hosts.txt b/net/adblock-fast/tests/data/hosts.txt
new file mode 100644 (file)
index 0000000..fab876b
--- /dev/null
@@ -0,0 +1,108 @@
+# Test hosts blocklist for adblock-fast functional tests
+# Title: StevenBlack Unified hosts (adware + malware)
+#
+# This block between "# Title: StevenBlack" and "# Custom host records"
+# is specifically removed by adblock-fast (lines 1589-1590).
+# Entries here should NOT appear in the output.
+# stevenblack-should-not-appear.test.example.com
+0.0.0.0 stevenblack-entry-hidden.test.example.com
+# Custom host records are listed here
+
+# -- Standard localhost entries (should be filtered) --
+127.0.0.1 localhost
+127.0.0.1 localhost.localdomain
+127.0.0.1 local
+0.0.0.0 0.0.0.0
+::1 localhost
+
+# -- Valid host entries with 0.0.0.0 prefix (40 unique) --
+0.0.0.0 adhost-zero-1.test.example.org
+0.0.0.0 adhost-zero-2.test.example.org
+0.0.0.0 adhost-zero-3.test.example.org
+0.0.0.0 adhost-zero-4.test.example.org
+0.0.0.0 adhost-zero-5.test.example.org
+0.0.0.0 adhost-zero-6.test.example.org
+0.0.0.0 adhost-zero-7.test.example.org
+0.0.0.0 adhost-zero-8.test.example.org
+0.0.0.0 adhost-zero-9.test.example.org
+0.0.0.0 adhost-zero-10.test.example.org
+0.0.0.0 adhost-zero-11.test.example.org
+0.0.0.0 adhost-zero-12.test.example.org
+0.0.0.0 adhost-zero-13.test.example.org
+0.0.0.0 adhost-zero-14.test.example.org
+0.0.0.0 adhost-zero-15.test.example.org
+0.0.0.0 adhost-zero-16.test.example.org
+0.0.0.0 adhost-zero-17.test.example.org
+0.0.0.0 adhost-zero-18.test.example.org
+0.0.0.0 adhost-zero-19.test.example.org
+0.0.0.0 adhost-zero-20.test.example.org
+0.0.0.0 adhost-zero-21.test.example.org
+0.0.0.0 adhost-zero-22.test.example.org
+0.0.0.0 adhost-zero-23.test.example.org
+0.0.0.0 adhost-zero-24.test.example.org
+0.0.0.0 adhost-zero-25.test.example.org
+0.0.0.0 adhost-zero-26.test.example.org
+0.0.0.0 adhost-zero-27.test.example.org
+0.0.0.0 adhost-zero-28.test.example.org
+0.0.0.0 adhost-zero-29.test.example.org
+0.0.0.0 adhost-zero-30.test.example.org
+0.0.0.0 adhost-zero-31.test.example.org
+0.0.0.0 adhost-zero-32.test.example.org
+0.0.0.0 adhost-zero-33.test.example.org
+0.0.0.0 adhost-zero-34.test.example.org
+0.0.0.0 adhost-zero-35.test.example.org
+0.0.0.0 adhost-zero-36.test.example.org
+0.0.0.0 adhost-zero-37.test.example.org
+0.0.0.0 adhost-zero-38.test.example.org
+0.0.0.0 adhost-zero-39.test.example.org
+0.0.0.0 adhost-zero-40.test.example.org
+
+# -- Valid host entries with 127.0.0.1 prefix (25 unique) --
+127.0.0.1 adhost-loopback-1.test.example.org
+127.0.0.1 adhost-loopback-2.test.example.org
+127.0.0.1 adhost-loopback-3.test.example.org
+127.0.0.1 adhost-loopback-4.test.example.org
+127.0.0.1 adhost-loopback-5.test.example.org
+127.0.0.1 adhost-loopback-6.test.example.org
+127.0.0.1 adhost-loopback-7.test.example.org
+127.0.0.1 adhost-loopback-8.test.example.org
+127.0.0.1 adhost-loopback-9.test.example.org
+127.0.0.1 adhost-loopback-10.test.example.org
+127.0.0.1 adhost-loopback-11.test.example.org
+127.0.0.1 adhost-loopback-12.test.example.org
+127.0.0.1 adhost-loopback-13.test.example.org
+127.0.0.1 adhost-loopback-14.test.example.org
+127.0.0.1 adhost-loopback-15.test.example.org
+127.0.0.1 adhost-loopback-16.test.example.org
+127.0.0.1 adhost-loopback-17.test.example.org
+127.0.0.1 adhost-loopback-18.test.example.org
+127.0.0.1 adhost-loopback-19.test.example.org
+127.0.0.1 adhost-loopback-20.test.example.org
+127.0.0.1 adhost-loopback-21.test.example.org
+127.0.0.1 adhost-loopback-22.test.example.org
+127.0.0.1 adhost-loopback-23.test.example.org
+127.0.0.1 adhost-loopback-24.test.example.org
+127.0.0.1 adhost-loopback-25.test.example.org
+
+# -- Domains shared with domains.txt (10 overlapping) --
+0.0.0.0 common-shared-1.test.example.com
+0.0.0.0 common-shared-2.test.example.com
+0.0.0.0 common-shared-3.test.example.com
+0.0.0.0 common-shared-4.test.example.com
+0.0.0.0 common-shared-5.test.example.com
+127.0.0.1 common-shared-6.test.example.com
+127.0.0.1 common-shared-7.test.example.com
+127.0.0.1 common-shared-8.test.example.com
+127.0.0.1 common-shared-9.test.example.com
+127.0.0.1 common-shared-10.test.example.com
+
+# -- Subdomain dedup test (children that should be removed when parent exists) --
+0.0.0.0 child.parent-dedup-1.test.example.com
+0.0.0.0 sub.child.parent-dedup-2.test.example.com
+0.0.0.0 deep.sub.parent-dedup-3.test.example.com
+
+# -- Invalid entries --
+some random text without IP prefix
+0.0.0.0
+127.0.0.1
+
diff --git a/net/adblock-fast/tests/lib/mocklib.uc b/net/adblock-fast/tests/lib/mocklib.uc
new file mode 100644 (file)
index 0000000..fd2bbf0
--- /dev/null
@@ -0,0 +1,242 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Hybrid mocklib for adblock-fast functional tests.
+//
+// Key difference from mwan4's mocklib:
+//   - Does NOT mock the 'fs' module (real filesystem operations)
+//   - Selectively overrides system() to block service management
+//     while passing through data-processing commands (sed/sort/grep/awk)
+//   - Mocks only 'uci' and 'ubus'
+
+'use strict';
+
+if (!exists(global, 'REQUIRE_SEARCH_PATH'))
+       global.REQUIRE_SEARCH_PATH = [];
+
+if (!exists(global, 'MOCK_SEARCH_PATH'))
+       global.MOCK_SEARCH_PATH = [];
+
+if (!exists(global, 'TRACE_CALLS'))
+       global.TRACE_CALLS = null;
+
+let _fs = require("fs");
+
+// Force reloading uci and ubus modules so our mocks intercept them.
+// Do NOT delete fs -- we want the REAL filesystem module.
+delete global.modules.uci;
+delete global.modules.ubus;
+
+let _log = (level, fmt, ...args) => {
+       let color, prefix;
+
+       switch (level) {
+       case 'info':
+               color = 34;
+               prefix = '!';
+               break;
+       case 'warn':
+               color = 33;
+               prefix = 'W';
+               break;
+       case 'error':
+               color = 31;
+               prefix = 'E';
+               break;
+       default:
+               color = 0;
+               prefix = 'I';
+       }
+
+       let f = sprintf("\u001b[%d;1m[%s] %s\u001b[0m", color, prefix, fmt);
+       warn(replace(sprintf(f, ...args), "\n", "\n    "), "\n");
+};
+
+let format_json = (data) => {
+       let rv;
+
+       let format_value = (value) => {
+               switch (type(value)) {
+               case "object":
+                       return sprintf("{ /* %d keys */ }", length(value));
+               case "array":
+                       return sprintf("[ /* %d items */ ]", length(value));
+               case "string":
+                       if (length(value) > 64)
+                               value = substr(value, 0, 64) + "...";
+                       return sprintf("%J", value);
+               default:
+                       return sprintf("%J", value);
+               }
+       };
+
+       switch (type(data)) {
+       case "object":
+               rv = "{";
+               let k = sort(keys(data));
+               for (let i, n in k)
+                       rv += sprintf("%s %J: %s", i ? "," : "", n, format_value(data[n]));
+               rv += " }";
+               break;
+       case "array":
+               rv = "[";
+               for (let i, v in data)
+                       rv += (i ? "," : "") + " " + format_value(v);
+               rv += " ]";
+               break;
+       default:
+               rv = format_value(data);
+       }
+
+       return rv;
+};
+
+let read_data_file = (path) => {
+       for (let dir in MOCK_SEARCH_PATH) {
+               let fd = _fs.open(dir + '/' + path, "r");
+
+               if (fd) {
+                       let data = fd.read("all");
+                       fd.close();
+                       return data;
+               }
+       }
+
+       return null;
+};
+
+let read_json_file = (path) => {
+       let data = read_data_file(path);
+
+       if (data != null) {
+               try {
+                       return json(data);
+               }
+               catch (e) {
+                       _log('error', "Unable to parse JSON data in %s: %s", path, e);
+                       return NaN;
+               }
+       }
+
+       return null;
+};
+
+let trace_call = (ns, func, args) => {
+       let msg = "[call] " +
+               (ns ? ns + "." : "") +
+               func;
+
+       for (let k, v in args) {
+               msg += ' ' + k + ' <';
+
+               switch (type(v)) {
+               case "array":
+               case "object":
+                       msg += format_json(v);
+                       break;
+               default:
+                       msg += v;
+               }
+
+               msg += '>';
+       }
+
+       switch (TRACE_CALLS) {
+       case '1':
+       case 'stdout':
+               _fs.stdout.write(msg + "\n");
+               break;
+       case 'stderr':
+               _fs.stderr.write(msg + "\n");
+               break;
+       }
+};
+
+// Prepend mocklib/ to REQUIRE_SEARCH_PATH so mock uci/ubus are found
+for (let pattern in REQUIRE_SEARCH_PATH) {
+       if (!match(pattern, /\*\.uc$/))
+               continue;
+
+       let path = replace(pattern, /\*/, 'mocklib'),
+           s = _fs.stat(path);
+
+       if (!s || s.type != 'file')
+               continue;
+
+       if (!length(global.MOCK_SEARCH_PATH))
+               global.MOCK_SEARCH_PATH = [ replace(path, /mocklib\.uc$/, '../mocks') ];
+
+       unshift(REQUIRE_SEARCH_PATH, replace(path, /mocklib\.uc$/, 'mocklib/*.uc'));
+       break;
+}
+
+if (!length(global.MOCK_SEARCH_PATH))
+       global.MOCK_SEARCH_PATH = [ './mocks' ];
+
+// Register global mocklib namespace
+global.mocklib = {
+       require: function(module) {
+               let path, res, ex;
+
+               if (type(REQUIRE_SEARCH_PATH) == "array" && index(REQUIRE_SEARCH_PATH[0], 'mocklib/*.uc') != -1)
+                       path = shift(REQUIRE_SEARCH_PATH);
+
+               try {
+                       res = require(module);
+               }
+               catch (e) {
+                       ex = e;
+               }
+
+               if (path)
+                       unshift(REQUIRE_SEARCH_PATH, path);
+
+               if (ex)
+                       die(ex);
+
+               return res;
+       },
+
+       I: (...args) => _log('info', ...args),
+       N: (...args) => _log('notice', ...args),
+       W: (...args) => _log('warn', ...args),
+       E: (...args) => _log('error', ...args),
+
+       format_json,
+       read_data_file,
+       read_json_file,
+       trace_call,
+};
+
+// Selectively override system() -- block service management, pass through data processing
+global.system = ((_orig_system) => function(argv, timeout) {
+       let cmd = '' + argv;
+
+       // Block commands that interact with system services or would hang
+       if (match(cmd, /^\/etc\/init\.d\//) ||
+           match(cmd, /\/usr\/bin\/logger\b/) ||
+           match(cmd, /^logger\b/) ||
+           match(cmd, /^sleep\b/) ||
+           match(cmd, /^resolveip\b/) ||
+           match(cmd, /^dnsmasq\s+--test/) ||
+           match(cmd, /^ipset\b/) ||
+           match(cmd, /^nft\b/) ||
+           match(cmd, /^chmod\b/) ||
+           match(cmd, /^chown\b/)) {
+               trace_call(null, "system[blocked]", { command: cmd });
+               return 0;
+       }
+
+       // Pass through real commands for data processing
+       return _orig_system(argv, timeout);
+})(global.system);
+
+// Override time() to return fixed value for reproducible tests
+global.time = function() {
+       return 1615382640;
+};
+
+// Override getenv -- return null to prevent env interference
+global.getenv = function(key) {
+       return null;
+};
+
+return global.mocklib;
diff --git a/net/adblock-fast/tests/lib/mocklib/ubus.uc b/net/adblock-fast/tests/lib/mocklib/ubus.uc
new file mode 100644 (file)
index 0000000..ee6d487
--- /dev/null
@@ -0,0 +1,69 @@
+// UBus mock for adblock-fast tests.
+// Reused from mwan4's mock with no changes.
+
+let mocklib = global.mocklib; // ucode-lsp disable
+
+return {
+       connect: function() {
+               let self = this;
+
+               return {
+                       call: (object, method, args) => {
+                               let signature = [ object + "~" + method ];
+
+                               if (type(args) == "object") {
+                                       for (let i, k in sort(keys(args))) {
+                                               switch (type(args[k])) {
+                                               case "string":
+                                               case "double":
+                                               case "bool":
+                                               case "int":
+                                                       push(signature, k + "-" + replace(args[k], /[^A-Za-z0-9_-]+/g, "_"));
+                                                       break;
+
+                                               default:
+                                                       push(signature, type(args[k]));
+                                               }
+                                       }
+                               }
+
+                               let candidates = [];
+
+                               for (let i = length(signature); i > 0; i--) {
+                                       let path = sprintf("ubus/%s.json", join("~", signature)),
+                                           mock = mocklib.read_json_file(path);
+
+                                       if (mock != mock) {
+                                               self._error = "Invalid argument";
+
+                                               return null;
+                                       }
+                                       else if (mock) {
+                                               mocklib.trace_call("ctx", "call", { object, method, args });
+
+                                               return mock;
+                                       }
+
+                                       push(candidates, path);
+                                       pop(signature);
+                               }
+
+                               // Return null silently for unmatched calls (non-critical in tests)
+                               self._error = "Method not found";
+
+                               return null;
+                       },
+
+                       disconnect: () => null,
+
+                       error: () => self.error()
+               };
+       },
+
+       error: function() {
+               let e = this._error;
+               delete this._error;
+
+               return e;
+       }
+};
diff --git a/net/adblock-fast/tests/lib/mocklib/uci.uc b/net/adblock-fast/tests/lib/mocklib/uci.uc
new file mode 100644 (file)
index 0000000..501ca1c
--- /dev/null
@@ -0,0 +1,213 @@
+// UCI mock for adblock-fast tests.
+// Based on mwan4's UCI mock, extended with set/save/commit/changes/delete.
+
+let mocklib = global.mocklib; // ucode-lsp disable
+
+let byte = (str, off) => { // ucode-lsp disable
+       let v = ord(str, off);
+       return length(v) ? v[0] : v;
+};
+
+let hash = (s) => { // ucode-lsp disable
+       let h = 7;
+
+       for (let i = 0; i < length(s); i++)
+               h = h * 31 + byte(s, i);
+
+       return h;
+};
+
+let id = (config, t, n) => { // ucode-lsp disable
+       while (true) {
+               let id = sprintf('cfg%08x', hash(t + n));
+
+               if (!exists(config, id))
+                       return id;
+
+               n++;
+       }
+};
+
+let fixup_config = (config) => { // ucode-lsp disable
+       let rv = {};
+       let n_section = 0;
+
+       for (let stype in config) {
+               switch (type(config[stype])) {
+               case 'object':
+                       config[stype] = [ config[stype] ];
+                       /* fall through */
+
+               case 'array':
+                       for (let idx, sobj in config[stype]) {
+                               let sid, anon;
+
+                               if (exists(sobj, '.name') && !exists(rv, sobj['.name'])) {
+                                       sid = sobj['.name'];
+                                       anon = false;
+                               }
+                               else {
+                                       sid = id(rv, stype, idx);
+                                       anon = true;
+                               }
+
+                               rv[sid] = {
+                                       '.index': n_section++,
+                                       ...sobj,
+                                       '.name': sid,
+                                       '.type': stype,
+                                       '.anonymous': anon
+                               };
+                       }
+
+                       break;
+               }
+       }
+
+       for (let n, sid in sort(keys(rv), (a, b) => rv[a]['.index'] - rv[b]['.index']))
+               rv[sid]['.index'] = n;
+
+       return rv;
+};
+
+return {
+       cursor: () => ({
+               _configs: {},
+               _dirty: {},
+
+               load: function(file) {
+                       let basename = replace(file, /^.+\//, ''),
+                           path = sprintf("uci/%s.json", basename),
+                           mock = mocklib.read_json_file(path);
+
+                       if (!mock || mock != mock) {
+                               mocklib.I("No configuration fixture defined for uci package %s.", file);
+                               mocklib.I("Provide a mock configuration through the following JSON file:\n%s\n", path);
+
+                               return null;
+                       }
+
+                       this._configs[basename] = fixup_config(mock);
+               },
+
+               _get_section: function(config, section) {
+                       if (!exists(this._configs, config)) {
+                               this.load(config);
+
+                               if (!exists(this._configs, config))
+                                       return null;
+                       }
+
+                       let cfg = this._configs[config],
+                           extended = match(section, "^@([A-Za-z0-9_-]+)\\[(-?[0-9]+)\\]$");
+
+                       if (extended) {
+                               let stype = extended[1],
+                                   sindex = +extended[2];
+
+                               let sids = sort(
+                                       filter(keys(cfg), sid => cfg[sid]['.type'] == stype),
+                                       (a, b) => cfg[a]['.index'] - cfg[b]['.index']
+                               );
+
+                               if (sindex < 0)
+                                       sindex = sids.length + sindex;
+
+                               return cfg[sids[sindex]];
+                       }
+
+                       return cfg[section];
+               },
+
+               get: function(config, section, option) {
+                       let sobj = this._get_section(config, section);
+
+                       if (option && index(option, ".") == 0)
+                               return null;
+                       else if (sobj && option)
+                               return sobj[option];
+                       else if (sobj)
+                               return sobj[".type"];
+               },
+
+               get_all: function(config, section) {
+                       return section ? this._get_section(config, section) : this._configs[config];
+               },
+
+               foreach: function(config, stype, cb) {
+                       let rv = false;
+
+                       if (!exists(this._configs, config))
+                               this.load(config);
+
+                       if (exists(this._configs, config)) {
+                               let cfg = this._configs[config],
+                                   sids = sort(keys(cfg), (a, b) => cfg[a]['.index'] - cfg[b]['.index']);
+
+                               for (let i, sid in sids) {
+                                       if (stype == null || cfg[sid]['.type'] == stype) {
+                                               if (cb({ ...(cfg[sid]) }) === false)
+                                                       break;
+
+                                               rv = true;
+                                       }
+                               }
+                       }
+
+                       return rv;
+               },
+
+               // -- Extensions for adblock-fast --
+
+               set: function(config, section, option, value) {
+                       let sobj = this._get_section(config, section);
+                       if (sobj) {
+                               sobj[option] = value;
+                               this._dirty[config] = true;
+                       }
+               },
+
+               save: function(config) {
+                       return true;
+               },
+
+               commit: function(config) {
+                       delete this._dirty[config];
+                       return true;
+               },
+
+               changes: function(config) {
+                       return this._dirty[config] ? [['set']] : [];
+               },
+
+               revert: function(config) {
+                       delete this._dirty[config];
+               },
+
+               delete: function(config, section, option) {
+                       if (option) {
+                               let sobj = this._get_section(config, section);
+                               if (sobj) delete sobj[option];
+                       } else if (section) {
+                               if (exists(this._configs, config))
+                                       delete this._configs[config][section];
+                       }
+               },
+
+               list_add: function(config, section, option, value) {
+                       let sobj = this._get_section(config, section);
+                       if (!sobj) return;
+                       if (type(sobj[option]) != 'array')
+                               sobj[option] = sobj[option] ? [sobj[option]] : [];
+                       push(sobj[option], value);
+                       this._dirty[config] = true;
+               },
+
+               list_remove: function(config, section, option, value) {
+                       let sobj = this._get_section(config, section);
+                       if (!sobj || type(sobj[option]) != 'array') return;
+                       sobj[option] = filter(sobj[option], v => v != value);
+                       this._dirty[config] = true;
+               },
+       })
+};
diff --git a/net/adblock-fast/tests/mocks/ubus/network.interface.wan~status.json b/net/adblock-fast/tests/mocks/ubus/network.interface.wan~status.json
new file mode 100644 (file)
index 0000000..2ae5bcc
--- /dev/null
@@ -0,0 +1,23 @@
+{
+       "up": true,
+       "pending": false,
+       "available": true,
+       "autostart": true,
+       "device": "eth0",
+       "l3_device": "eth0",
+       "proto": "dhcp",
+       "ipv4-address": [
+               {
+                       "address": "192.168.1.100",
+                       "mask": 24
+               }
+       ],
+       "route": [
+               {
+                       "target": "0.0.0.0",
+                       "mask": 0,
+                       "nexthop": "192.168.1.1",
+                       "source": "192.168.1.100/24"
+               }
+       ]
+}
diff --git a/net/adblock-fast/tests/mocks/ubus/network.interface~dump.json b/net/adblock-fast/tests/mocks/ubus/network.interface~dump.json
new file mode 100644 (file)
index 0000000..2d9b893
--- /dev/null
@@ -0,0 +1,24 @@
+{
+       "interface": [
+               {
+                       "interface": "loopback",
+                       "up": true,
+                       "device": "lo",
+                       "l3_device": "lo",
+                       "route": []
+               },
+               {
+                       "interface": "wan",
+                       "up": true,
+                       "device": "eth0",
+                       "l3_device": "eth0",
+                       "route": [
+                               {
+                                       "target": "0.0.0.0",
+                                       "mask": 0,
+                                       "nexthop": "192.168.1.1"
+                               }
+                       ]
+               }
+       ]
+}
diff --git a/net/adblock-fast/tests/mocks/ubus/service~list~name-dnsmasq.json b/net/adblock-fast/tests/mocks/ubus/service~list~name-dnsmasq.json
new file mode 100644 (file)
index 0000000..212fb2e
--- /dev/null
@@ -0,0 +1,11 @@
+{
+       "dnsmasq": {
+               "instances": {
+                       "cfg01411c": {
+                               "running": true,
+                               "pid": 1234,
+                               "command": [ "/usr/sbin/dnsmasq", "-C", "/var/etc/dnsmasq.conf.cfg01411c", "-k", "-x", "/var/run/dnsmasq/dnsmasq.cfg01411c.pid" ]
+                       }
+               }
+       }
+}
diff --git a/net/adblock-fast/tests/mocks/ubus/system~info.json b/net/adblock-fast/tests/mocks/ubus/system~info.json
new file mode 100644 (file)
index 0000000..c61c5b7
--- /dev/null
@@ -0,0 +1,17 @@
+{
+       "memory": {
+               "total": 536870912,
+               "available": 268435456,
+               "free": 134217728,
+               "cached": 67108864,
+               "buffered": 33554432,
+               "shared": 4194304
+       },
+       "swap": {
+               "total": 0,
+               "free": 0
+       },
+       "uptime": 86400,
+       "localtime": 1615382640,
+       "load": [ 0, 0, 0 ]
+}
diff --git a/net/adblock-fast/tests/mocks/uci/adblock-fast.json b/net/adblock-fast/tests/mocks/uci/adblock-fast.json
new file mode 100644 (file)
index 0000000..e30ea20
--- /dev/null
@@ -0,0 +1,44 @@
+{
+       "config": [
+               {
+                       ".name": "config",
+                       ".type": "config",
+                       "enabled": "1",
+                       "dns": "dnsmasq.servers",
+                       "verbosity": "2",
+                       "force_dns": "0",
+                       "compressed_cache": "0",
+                       "config_update_enabled": "0",
+                       "ipv6_enabled": "0",
+                       "canary_domains_icloud": "0",
+                       "canary_domains_mozilla": "0",
+                       "dnsmasq_sanity_check": "0",
+                       "dnsmasq_validity_check": "0",
+                       "parallel_downloads": "0",
+                       "allow_non_ascii": "0",
+                       "update_config_sizes": "0",
+                       "heartbeat_domain": "-",
+                       "download_timeout": "10",
+                       "curl_retry": "1",
+                       "compressed_cache_dir": "TESTDIR/cache"
+               }
+       ],
+       "file_url": [
+               {
+                       ".name": "blocked_domains",
+                       ".type": "file_url",
+                       "enabled": "1",
+                       "url": "file://TESTDIR/data/domains.txt",
+                       "action": "block",
+                       "name": "Test Domains"
+               },
+               {
+                       ".name": "blocked_hosts",
+                       ".type": "file_url",
+                       "enabled": "1",
+                       "url": "file://TESTDIR/data/hosts.txt",
+                       "action": "block",
+                       "name": "Test Hosts"
+               }
+       ]
+}
diff --git a/net/adblock-fast/tests/mocks/uci/dhcp.json b/net/adblock-fast/tests/mocks/uci/dhcp.json
new file mode 100644 (file)
index 0000000..d800ffa
--- /dev/null
@@ -0,0 +1,23 @@
+{
+       "dnsmasq": [
+               {
+                       ".name": "cfg01411c",
+                       ".type": "dnsmasq",
+                       ".anonymous": true,
+                       "domainneeded": "1",
+                       "localise_queries": "1",
+                       "rebind_protection": "1",
+                       "rebind_localhost": "1",
+                       "local": "/lan/",
+                       "domain": "lan",
+                       "expandhosts": "1",
+                       "cachesize": "1000",
+                       "authoritative": "1",
+                       "readethers": "1",
+                       "leasefile": "/tmp/dhcp.leases",
+                       "resolvfile": "/tmp/resolv.conf.d/resolv.conf.auto",
+                       "localservice": "1",
+                       "ednspacket_max": "1232"
+               }
+       ]
+}
diff --git a/net/adblock-fast/tests/mocks/uci/network.json b/net/adblock-fast/tests/mocks/uci/network.json
new file mode 100644 (file)
index 0000000..5b33d20
--- /dev/null
@@ -0,0 +1,18 @@
+{
+       "interface": [
+               {
+                       ".name": "loopback",
+                       ".type": "interface",
+                       "device": "lo",
+                       "proto": "static",
+                       "ipaddr": "127.0.0.1",
+                       "netmask": "255.0.0.0"
+               },
+               {
+                       ".name": "wan",
+                       ".type": "interface",
+                       "device": "eth0",
+                       "proto": "dhcp"
+               }
+       ]
+}
diff --git a/net/adblock-fast/tests/mocks/uci/smartdns.json b/net/adblock-fast/tests/mocks/uci/smartdns.json
new file mode 100644 (file)
index 0000000..a4885b6
--- /dev/null
@@ -0,0 +1,9 @@
+{
+       "smartdns": [
+               {
+                       ".name": "smartdns",
+                       ".type": "smartdns",
+                       "enabled": "0"
+               }
+       ]
+}
diff --git a/net/adblock-fast/tests/mocks/uci/unbound.json b/net/adblock-fast/tests/mocks/uci/unbound.json
new file mode 100644 (file)
index 0000000..46b13b5
--- /dev/null
@@ -0,0 +1,9 @@
+{
+       "unbound": [
+               {
+                       ".name": "unbound",
+                       ".type": "unbound",
+                       "enabled": "0"
+               }
+       ]
+}
diff --git a/net/adblock-fast/tests/run_tests.sh b/net/adblock-fast/tests/run_tests.sh
new file mode 100644 (file)
index 0000000..90eb12b
--- /dev/null
@@ -0,0 +1,381 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: AGPL-3.0-or-later
+# Functional test runner for adblock-fast.
+#
+# Adapts the mwan4 mock-and-expect pattern for adblock-fast:
+#   - Patches ES module imports to require() calls
+#   - Redirects hardcoded paths to a temp directory
+#   - Exports internal functions for test access
+#   - Uses real shell commands (sed/sort/grep/awk) with mock UCI/UBus
+#
+# Usage: cd source.openwrt.melmac.ca/adblock-fast && bash tests/run_tests.sh [test_file...]
+
+set -o pipefail
+
+line='........................................'
+
+# ── Temp directories ─────────────────────────────────────────────────
+
+TESTDIR="/tmp/adb_test.$$"
+patch_dir="/tmp/adb_test_modules.$$"
+stub_dir="$TESTDIR/stubs"
+
+mkdir -p "$TESTDIR"/{var_run/adblock-fast,var,shm,var_lib_unbound,etc,cache,tmp}
+mkdir -p "$patch_dir"
+mkdir -p "$stub_dir"
+
+trap "rm -rf '$TESTDIR' '$patch_dir'" EXIT
+
+# ── Copy test data ───────────────────────────────────────────────────
+
+cp -r ./tests/data "$TESTDIR/data"
+
+# ── Prepare resolved mock fixtures (replace TESTDIR placeholder) ─────
+
+mkdir -p "$TESTDIR/mocks_resolved/uci" "$TESTDIR/mocks_resolved/ubus"
+for f in ./tests/mocks/uci/*.json; do
+       sed "s|TESTDIR|$TESTDIR|g" "$f" > "$TESTDIR/mocks_resolved/uci/$(basename "$f")"
+done
+for f in ./tests/mocks/ubus/*.json; do
+       cp "$f" "$TESTDIR/mocks_resolved/ubus/$(basename "$f")"
+done
+
+# ── Create resolver stubs ───────────────────────────────────────────
+
+cat > "$stub_dir/dnsmasq" << 'STUB'
+#!/bin/sh
+case "$1" in
+    --version)
+        echo "Dnsmasq version 2.89"
+        echo "Compile time options: IPv6 GNU-getopt no-DBus no-UBus no-i18n no-IDN DHCP DHCPv6 no-Lua TFTP no-conntrack ipset nftset auth no-cryptohash no-DNSSEC loop-detect inotify dumpfile"
+        ;;
+    --test)
+        echo "dnsmasq: syntax check OK."
+        exit 0
+        ;;
+esac
+STUB
+chmod +x "$stub_dir/dnsmasq"
+
+for cmd in smartdns unbound; do
+       printf '#!/bin/sh\nexit 0\n' > "$stub_dir/$cmd"
+       chmod +x "$stub_dir/$cmd"
+done
+
+# Create ipset/nft stubs
+for cmd in ipset nft; do
+       printf '#!/bin/sh\nexit 0\n' > "$stub_dir/$cmd"
+       chmod +x "$stub_dir/$cmd"
+done
+
+# Create resolveip stub
+cat > "$stub_dir/resolveip" << 'STUB'
+#!/bin/sh
+echo "127.0.0.1"
+exit 0
+STUB
+chmod +x "$stub_dir/resolveip"
+
+# ── Patch adblock-fast.uc ───────────────────────────────────────────
+
+# The sed pipeline:
+#   1. Convert ES module imports to require() calls
+#   2. Redirect hardcoded paths to TESTDIR
+#   3. Extend is_present() search paths with stub_dir
+#   4. Export internal test helpers
+
+sed \
+       -e "s|import { readfile, writefile, popen, stat, unlink, rename, open, glob, mkdir, mkstemp, symlink, chmod, chown, realpath, lsdir, access, dirname } from 'fs';|let _fs = require('fs'), readfile = _fs.readfile, writefile = _fs.writefile, popen = _fs.popen, stat = _fs.stat, unlink = _fs.unlink, rename = _fs.rename, open = _fs.open, glob = _fs.glob, mkdir = _fs.mkdir, mkstemp = _fs.mkstemp, symlink = _fs.symlink, chmod = _fs.chmod, chown = _fs.chown, realpath = _fs.realpath, lsdir = _fs.lsdir, access = _fs.access, dirname = _fs.dirname;|" \
+       -e "s|import { cursor } from 'uci';|let _uci = require('uci'), cursor = _uci.cursor;|" \
+       -e "s|import { connect } from 'ubus';|let _ubus = require('ubus'), connect = _ubus.connect;|" \
+       -e "s|dnsmasq_file: '/var/run/adblock-fast/adblock-fast.dnsmasq'|dnsmasq_file: '${TESTDIR}/var_run/adblock-fast/adblock-fast.dnsmasq'|" \
+       -e "s|config_file: '/etc/config/adblock-fast'|config_file: '${TESTDIR}/etc/adblock-fast'|" \
+       -e "s|run_file: '/dev/shm/adblock-fast'|run_file: '${TESTDIR}/shm/adblock-fast'|" \
+       -e "s|status_file: '/dev/shm/adblock-fast.status.json'|status_file: '${TESTDIR}/shm/adblock-fast.status.json'|" \
+       -e "s|'/var/run/' + pkg.name|'${TESTDIR}/var_run/' + pkg.name|g" \
+       -e "s|'/var/lib/unbound/adb_list.' + pkg.name|'${TESTDIR}/var_run/' + pkg.name + '/adb_list.' + pkg.name|g" \
+       -e "s|'/var/' + pkg.name|'${TESTDIR}/var/' + pkg.name|g" \
+       -e "s|for (let dir in \['/usr/sbin', '/usr/bin', '/sbin', '/bin'\])|for (let dir in ['${stub_dir}', '/usr/sbin', '/usr/bin', '/sbin', '/bin'])|" \
+       -e "s|stat('/etc/config/dhcp')|stat('${TESTDIR}/etc/dhcp')|g" \
+       -e "s|stat('/etc/config/smartdns')|stat('${TESTDIR}/etc/smartdns')|g" \
+       ./files/lib/adblock-fast/adblock-fast.uc > "$patch_dir/adblock-fast.uc"
+
+# Append test-helper exports to the patched module.
+# We add a _test_internals object that gives tests access to module-private state.
+# NOTE: cfg is accessed via get_cfg()/set_cfg() because env.load_config()
+# reassigns cfg, which would make a direct reference stale.
+sed -i '/^export default {/,/^};/{
+       /process_file_url,/a\
+\t// Test helpers (injected by test runner)\
+\t_test_internals: {\
+\t\tdownload_lists: download_lists,\
+\t\tdetect_file_type: detect_file_type,\
+\t\tdns_modes: dns_modes,\
+\t\tget_cfg: function() { return cfg; },\
+\t\tset_cfg: function(k, v) { cfg[k] = v; },\
+\t\tstate: state,\
+\t\tenv: env,\
+\t\tdns_output: dns_output,\
+\t\tstatus_data: status_data,\
+\t\tlist_formats: list_formats,\
+\t\ttmp: tmp,\
+\t\tappend_urls: append_urls,\
+\t\tcount_lines: count_lines,\
+\t\tcount_blocked_domains: count_blocked_domains,\
+\t},
+}' "$patch_dir/adblock-fast.uc"
+
+# Patch cli.uc too (for tests that exercise the CLI path)
+sed \
+       -e "s|import adb from 'adblock-fast';|let adb = require('adblock-fast');|" \
+       ./files/lib/adblock-fast/cli.uc > "$patch_dir/cli.uc"
+
+# ── Set up environment ───────────────────────────────────────────────
+
+export TMPDIR="$TESTDIR/tmp"
+export PATH="$stub_dir:$PATH"
+
+# ucode invocation: patched module first, then mocklib, then original source
+ucode="ucode -S -L$patch_dir -L./tests/lib -L./files/lib/adblock-fast"
+
+# ── Test framework (adapted from mwan4) ──────────────────────────────
+
+extract_sections() {
+       local file=$1
+       local dir=$2
+       local count=0
+       local tag line outfile
+
+       while IFS= read -r line; do
+               case "$line" in
+                       "-- Testcase --")
+                               tag="test"
+                               count=$((count + 1))
+                               outfile=$(printf "%s/%03d.in" "$dir" $count)
+                               printf "" > "$outfile"
+                       ;;
+                       "-- Environment --")
+                               tag="env"
+                               count=$((count + 1))
+                               outfile=$(printf "%s/%03d.env" "$dir" $count)
+                               printf "" > "$outfile"
+                       ;;
+                       "-- Expect stdout --"|"-- Expect stderr --"|"-- Expect exitcode --")
+                               tag="${line#-- Expect }"
+                               tag="${tag% --}"
+                               count=$((count + 1))
+                               outfile=$(printf "%s/%03d.%s" "$dir" $count "$tag")
+                               printf "" > "$outfile"
+                       ;;
+                       "-- File "*" --")
+                               tag="file"
+                               outfile="${line#-- File }"
+                               outfile="$(echo "${outfile% --}" | xargs)"
+                               outfile="$dir/files$(readlink -m "/${outfile:-file}")"
+                               mkdir -p "$(dirname "$outfile")"
+                               printf "" > "$outfile"
+                       ;;
+                       "-- End --")
+                               tag=""
+                               outfile=""
+                       ;;
+                       *)
+                               if [ -n "$tag" ]; then
+                                       printf "%s\\n" "$line" >> "$outfile"
+                               fi
+                       ;;
+               esac
+       done < "$file"
+
+       # Post-process: replace TESTDIR placeholder in extracted files
+       # - files/ directory (mock data)
+       # - expect sections (.stdout, .stderr) so tests can reference TESTDIR paths
+       # NOTE: Do NOT substitute in .in files — those use TESTDIR as a ucode global variable
+       find "$dir/files" -type f 2>/dev/null | while read -r f; do
+               sed -i "s|TESTDIR|$TESTDIR|g" "$f"
+       done
+       for f in "$dir"/*.stdout "$dir"/*.stderr; do
+               [ -f "$f" ] && sed -i "s|TESTDIR|$TESTDIR|g" "$f"
+       done
+
+       return $(ls -l "$dir/"*.in 2>/dev/null | wc -l)
+}
+
+run_testcase() {
+       local num=$1
+       local dir=$2
+       local in=$3
+       local env=$4
+       local out=$5
+       local err=$6
+       local code=$7
+       local fail=0
+
+       # Clean test state between runs
+       rm -rf "$TESTDIR"/var_run/adblock-fast/*
+       rm -f "$TESTDIR"/var/adblock-fast.*
+       rm -f "$TESTDIR"/shm/adblock-fast*
+       rm -f "$TESTDIR"/var_lib_unbound/*
+       rm -f "$TESTDIR"/tmp/adblock-fast*
+       mkdir -p "$TESTDIR"/var_run/adblock-fast
+
+       $ucode \
+               -D MOCK_SEARCH_PATH='["'"$dir"'/files", "'"$TESTDIR"'/mocks_resolved", "./tests/mocks"]' \
+               -D TESTDIR='"'"$TESTDIR"'"' \
+               ${env:+-F "$env"} \
+               -l mocklib \
+               - <"$in" >"$dir/res.out" 2>"$dir/res.err"
+
+       printf "%d\n" $? > "$dir/res.code"
+
+       touch "$dir/empty"
+
+       if ! cmp -s "$dir/res.err" "${err:-$dir/empty}"; then
+               [ $fail = 0 ] && printf "!\n"
+               printf "Testcase #%d: Expected stderr did not match:\n" $num
+               diff -u --color=always --label="Expected stderr" --label="Resulting stderr" "${err:-$dir/empty}" "$dir/res.err"
+               printf -- "---\n"
+               fail=1
+       fi
+
+       if ! cmp -s "$dir/res.out" "${out:-$dir/empty}"; then
+               [ $fail = 0 ] && printf "!\n"
+               printf "Testcase #%d: Expected stdout did not match:\n" $num
+               diff -u --color=always --label="Expected stdout" --label="Resulting stdout" "${out:-$dir/empty}" "$dir/res.out"
+               printf -- "---\n"
+               fail=1
+       fi
+
+       if [ -n "$code" ] && ! cmp -s "$dir/res.code" "$code"; then
+               [ $fail = 0 ] && printf "!\n"
+               printf "Testcase #%d: Expected exit code did not match:\n" $num
+               diff -u --color=always --label="Expected code" --label="Resulting code" "$code" "$dir/res.code"
+               printf -- "---\n"
+               fail=1
+       fi
+
+       return $fail
+}
+
+run_test() {
+       local file=$1
+       local name=${file##*/}
+       local res ecode eout eerr ein eenv tests
+       local testcase_first=0 failed=0 count=0
+
+       printf "%s %s " "$name" "${line:${#name}}"
+
+       mkdir "/tmp/test.$$"
+
+       extract_sections "$file" "/tmp/test.$$"
+       tests=$?
+
+       [ -f "/tmp/test.$$/001.in" ] && testcase_first=1
+
+       for res in "/tmp/test.$$/"[0-9]*; do
+               case "$res" in
+                       *.in)
+                               count=$((count + 1))
+
+                               if [ $testcase_first = 1 ]; then
+                                       # Flush previous test
+                                       if [ -n "$ein" ]; then
+                                               run_testcase $count "/tmp/test.$$" "$ein" "$eenv" "$eout" "$eerr" "$ecode" || failed=$((failed + 1))
+
+                                               eout=""
+                                               eerr=""
+                                               ecode=""
+                                               eenv=""
+                                       fi
+
+                                       ein=$res
+                               else
+                                       run_testcase $count "/tmp/test.$$" "$res" "$eenv" "$eout" "$eerr" "$ecode" || failed=$((failed + 1))
+
+                                       eout=""
+                                       eerr=""
+                                       ecode=""
+                                       eenv=""
+                               fi
+
+                       ;;
+                       *.env) eenv=$res ;;
+                       *.stdout) eout=$res ;;
+                       *.stderr) eerr=$res ;;
+                       *.exitcode) ecode=$res ;;
+               esac
+       done
+
+       # Flush last test
+       if [ $testcase_first = 1 ] && [ -n "$ein" ]; then
+               run_testcase $count "/tmp/test.$$" "$ein" "$eenv" "$eout" "$eerr" "$ecode" || failed=$((failed + 1))
+       fi
+
+       rm -r "/tmp/test.$$"
+
+       if [ $failed = 0 ]; then
+               printf "OK\n"
+       else
+               printf "%s %s FAILED (%d/%d)\n" "$name" "${line:${#name}}" $failed $tests
+       fi
+
+       return $failed
+}
+
+
+n_tests=0
+n_fails=0
+
+select_tests="$@"
+
+use_test() {
+       local input="$(readlink -f "$1")"
+       local test
+
+       [ -f "$input" ] || return 1
+       [ -n "$select_tests" ] || return 0
+
+       for test in $select_tests; do
+               test="$(readlink -f "$test")"
+
+               [ "$test" != "$input" ] || return 0
+       done
+
+       return 1
+}
+
+for catdir in tests/[0-9][0-9]_*; do
+       [ -d "$catdir" ] || continue
+
+       printf "\n##\n## Running %s tests\n##\n\n" "${catdir##*/[0-9][0-9]_}"
+
+       for testfile in "$catdir/"[0-9][0-9]_*; do
+               use_test "$testfile" || continue
+
+               n_tests=$((n_tests + 1))
+               run_test "$testfile" || n_fails=$((n_fails + 1))
+       done
+done
+
+# ── Shell script syntax checks ──────────────────────────────────────
+
+printf "\n##\n## Checking shell script syntax\n##\n\n"
+for shellscript in \
+       files/etc/init.d/* \
+       files/etc/uci-defaults/*; do
+       [ -f "$shellscript" ] || continue
+       head -1 "$shellscript" | grep -q '^#!/bin/sh' || continue
+       name="${shellscript#files/}"
+       n_tests=$((n_tests + 1))
+       printf "%s %s " "$name" "${line:${#name}}"
+       if sh -n "$shellscript" 2>/dev/null; then
+               printf "OK\n"
+       else
+               printf "FAIL\n"
+               sh -n "$shellscript"
+               n_fails=$((n_fails + 1))
+       fi
+done
+
+printf "\nRan %d tests, %d okay, %d failures\n" $n_tests $((n_tests - n_fails)) $n_fails
+exit $n_fails
git clone https://git.99rst.org/PROJECT