[1.6.6] - 2026-05-25
Added
- Notification email templating for DNS Schedules. Both the (new) subject template field and the existing
notifyMessagebody now support{token}substitution. Tokens are documented inline in the form via a chip palette that supports drag-and-drop into either field and click-to-insert at the last-focused field's cursor. A live preview pane below the chips renders against sample values derived from the current draft so the operator can see exactly how the email will look before saving. - 15 substitution tokens, split between static (snapshotted from the schedule at sync time) and dynamic (rendered at email send):
{scheduleName},{scheduleId},{startTime}/{startTime12},{endTime}/{endTime12},{timezone},{daysOfWeek},{action},{groups},{matchedCount},{latestMatchAt},{domain},{rootDomain},{client},{nodeId}. The*12time variants render 12-hour clock with AM/PM (e.g. "10:00 PM") alongside the 24-hour{startTime}/{endTime}. Unknown tokens like{statTime}are left literal in the delivered email so typos are visible. {rootDomain}token computes the registrable domain via the Public Suffix List (tldts ^7.1.2). For an alert onrr1---sn-aigl6nsd.googlevideo.com, the substitution rendersgooglevideo.com; fornews.bbc.co.ukit correctly preservesbbc.co.uksinceco.ukis a public suffix. Cleans up long, machine-generated subdomains in notification copy without losing the registrable identity.
Fixed
- DNS Schedules orphaned entries when the schedule's domain source changed mid-window. Swapping a schedule's Domain Group (e.g. YouTube → Spotify) on the same target AB group caused the previously-written YouTube entries to remain in
Parents.blockedindefinitely — the remove path re-resolved the schedule's current definition instead of cleaning up what was actually written. Fixed via a newdns_schedule_applied_entriestable that records every(schedule, node, AB group, action, domain)tuple at apply time. The apply pass is now diff-driven: prev-tracked vs now-resolved computes additions and removals atomically, then commits tracking after a successfulsetConfig. Handles all four trigger vectors (domain-group swap, target-AB-group swap, action flip, manual entries edit) cleanly. Pre-existing orphans from earlier versions need to be removed manually via the Technitium UI. - Multi-schedule overlap silently broke blocking. When two schedules targeted the same AB group with the same Domain Group (e.g.
Nighttime Block (Ed)andCopy of Nighttime Block (Ed)both blocking YouTube on Edison), they each populated tracking with identical tuples. When one schedule'sremovefired (toggle off, edit, window close), it stripped the shared entries from the AB config — but the other schedule's next apply tick computed its diff asprev = desired = YouTube tuples→toAdd = ∅→ never re-added them, even though live state was now missing them. Result: blocking silently stopped working until something forced a real diff. Fixed by computingtoAddagainst LIVE state, not just against prev-tracked: any desired tuple missing from the current AB config gets re-added regardless of what we previously tracked. Same code path also self-heals external mutations (manual UI edits, conflicting Domain Groups applies) on the same tick instead of waiting for the existing N-consecutive-tick drift detector to alert. - Blocklist refresh hammered upstream sources (oisd.nl 429s) (closes #70). Four patterns in
DomainListCacheServicemadeToo Many Requestsresponses likely from upstream blocklist providers even at the 8h refresh interval:- Conditional GET headers added. The cache already saved each response's
ETagandLast-Modifiedto persistence but never sent them back on the next refresh. ThegetOrFetchList/getOrFetchRegexListpaths now sendIf-None-Match/If-Modified-Sincefrom the cached entry; a304 Not Modifiedresponse refreshes the in-memoryfetchedAtand re-persists the same content without re-downloading the body. For large lists (oisd ~50 MB) this turns most refreshes into near-zero-bandwidth round-trips, and most CDNs don't count 304s against per-IP rate limits. - In-flight request coalescing. When multiple nodes (or concurrent code paths on the same node) ask for the same URL at the same time, only one HTTP request goes out — all callers await the same
Promiseand update their per-node caches from the shared result. Keyed by URL hash, since the upstream content is identical regardless of which node is asking. Cuts the cold-start-storm traffic by N for an N-node cluster sharing the same blocklist URLs. - Bounded fetch concurrency with jitter. Replaced the previous
Promise.all(urls.map(...))burst ingetOrFetchMultiple/getOrFetchMultipleRegexwith arunWithConcurrencyLimitworker queue (default 3 in-flight, 0–300ms jitter per start). Prevents Cloudflare per-IP burst-limit trips when several URLs refresh in the same tick. Retry-Afterhonored on 429/503. Upstream rate-limit responses are now parsed for theRetry-Afterheader (seconds or HTTP-date form). The URL enters a back-off state for the indicated duration (default 1h fallback); subsequent fetches for the same URL throw a "back-off active" error during the window without sending another request. A successful fetch or 304 clears the back-off. Logged at WARN with the duration so operators can see the back-off engaging.
- Conditional GET headers added. The cache already saved each response's
- Silent notification suppression for schedules targeting large Domain Groups. The auto-generated regex pattern on the linked Log Alert rule (one alternation per resolved domain) easily exceeded the 300-character cap for a Domain Group like YouTube, so
syncLinkedAlertRulefailed and notifications never fired during the schedule's active window. The cap is now 8000 chars; for genuinely huge groups (>~300 domains, regex > 7500 chars)buildAlertDomainPatternfalls back to wildcard*and a backend WARN logs that the alert scope has widened to "any blocked query in the target group during the active window." Both behaviors are correct since the linked rule is also gated by group selector + active-window enable/disable. - Email header injection defense-in-depth. Internal CR/LF/tab characters are now stripped from schedule names and notification subject templates at the API input boundary. Nodemailer 8.x rejects CRLF in subject headers (silent notification loss); sanitizing here keeps delivery working regardless of transport behavior.
- Migration-window cleanup gap. Pre-tracking-table schedules that hit their first window-close after upgrade fall back to the legacy resolve-from-schedule-definition cleanup instead of silently returning. Logs a one-time
LOGline per (schedule, node) when the legacy path engages.
Changed
- Per-tick tracking write churn eliminated.
setAppliedEntriesnow only fires when the desired set actually differs from prev (toRemove/toAdd non-empty OR size mismatch). Steady-state schedules no longer run aDELETE+ N×INSERTreplace cycle on every 60s tick. - Docker base image bumped from
node:22-alpine3.21tonode:24-alpine3.22. Aligns the production multi-stage build with the recent CI bump to Node 24-compatible action majors. Alpine 3.22 (May 2025) brings OpenSSL 3.3 and musl 1.2.5 with security fixes since 3.21. Thenodeuser (uid 1000) is unchanged across Node majors, so existing--chown=node:nodedirectives continue to work. AppInput/AppTextareawrapped inforwardRef. Required for the new chip-cursor-insertion logic inAutomationPage.tsx. Audited all consumers via grep — no pre-existing call sites were passingref, so the migration has zero blast radius elsewhere.
Security
- 5 transitive vulnerabilities resolved via
npm audit fix(lockfile-only, nopackage.jsonchanges):@babel/plugin-transform-modules-systemjs7.29.0 → 7.29.4 (high, CVSS 8.2 — arbitrary code generation on malicious input, GHSA-fv7c-fp4j-7gwp)fast-uri3.1.0 → 3.1.2 (high — path traversal via percent-encoded dot segments, GHSA-q3j6-qgpj-74h6 and GHSA-v39h-62p7-jpjc)qs6.15.1 → 6.15.2 (moderate —qs.stringifyDoS on null entries in comma-format arrays withencodeValuesOnly, GHSA-q8mj-m7cp-5q26)ws8.20.0 → 8.21.0 (moderate — uninitialized memory disclosure, GHSA-58qx-3vcg-4xpx)brace-expansion5.0.5 → 5.0.6 in 4 locations: eslint, glob, test-exclude, workbox-build (moderate — numeric range DoS bypass, GHSA-jxxr-4gwj-5jf2)
Testing
- 27 new backend tests (303 total, up from 273): orphan-prevention across all four trigger vectors, migration safety for empty-tracking apply and remove, pattern-length handling including wildcard fallback,
renderTemplatesubstitution semantics,extractDynamicTokensFromSampleparser (including sentinel-value filtering),computeRootDomainPSL coverage (multi-part ICANN suffixes, private-suffix collapse, IP/single-label fallback),notifySubjectTemplateparseDraft handling (trim, normalize, built-in-mode rejection). extractDynamicTokensFromSampleuses strict!== EXPECTED_SAMPLE_FIELD_COUNTinstead of< 5so the wrong values can't silently end up in alert emails ifformatSampleLine's schema ever drifts.