diff --git a/gately/Makefile b/gately/Makefile index 8ddb7f319..b3edf4184 100644 --- a/gately/Makefile +++ b/gately/Makefile @@ -5,8 +5,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=gately -PKG_VERSION:=0.6.4 -PKG_RELEASE:=25 +PKG_VERSION:=0.6.4.1 +PKG_RELEASE:=26 PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE_URL:=https://codeload.github.com/wangwanjie/gately/tar.gz/v$(PKG_VERSION)? diff --git a/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js b/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js index b0f0e060a..43e6ccac6 100644 --- a/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js +++ b/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js @@ -70,6 +70,210 @@ function hasDotDotSegment(value) { return value.split('/').indexOf('..') !== -1; } +// ---- BEGIN new-release notice ------------------------------------------------ +// AdGuard Home's own /control/version.json needs HTTP basic auth (this page only +// ever holds the password hash) and is served on another port, so the page asks +// GitHub directly: the same source as the "Check update" button and update_core.sh. +// The answer is cached in localStorage so opening the page does not cost an API +// request every time (GitHub allows only 60 unauthenticated requests per hour). +const STORAGE_KEY_LATEST = 'luci-app-adguardhome_latest_release'; +const RELEASE_CACHE_TTL = 6 * 60 * 60 * 1000; // after a successful check +const RELEASE_RETRY_DELAY = 30 * 60 * 1000; // after a failed check +const RELEASE_FETCH_TIMEOUT = 8000; + +function storageGet(key) { + try { + return window.localStorage.getItem(key); + } catch (e) { + return null; + } +} + +function storageSet(key, value) { + try { + window.localStorage.setItem(key, value); + } catch (e) {} +} + +// "v0.107.57-b.2" -> { nums: [0, 107, 57], pre: ['b', '2'] }, or null +function parseVersion(str) { + const m = /v?(\d+)\.(\d+)\.(\d+)(?:-([A-Za-z0-9.]+))?/.exec(String(str || '')); + if (!m) { + return null; + } + return { nums: [+m[1], +m[2], +m[3]], pre: m[4] ? m[4].split('.') : [] }; +} + +function formatVersion(v) { + return 'v' + v.nums.join('.') + (v.pre.length ? '-' + v.pre.join('.') : ''); +} + +// Comparator result (<0, 0, >0). A pre-release sorts below its release. +function compareVersions(a, b) { + for (let i = 0; i < 3; i++) { + if (a.nums[i] !== b.nums[i]) { + return a.nums[i] - b.nums[i]; + } + } + if (!a.pre.length && !b.pre.length) { + return 0; + } + if (!a.pre.length) { + return 1; + } + if (!b.pre.length) { + return -1; + } + const n = Math.max(a.pre.length, b.pre.length); + for (let i = 0; i < n; i++) { + const x = a.pre[i]; + const y = b.pre[i]; + if (x === undefined) { + return -1; + } + if (y === undefined) { + return 1; + } + if (x === y) { + continue; + } + const xNum = /^\d+$/.test(x); + const yNum = /^\d+$/.test(y); + if (xNum && yNum) { + return +x - +y; + } + if (xNum) { + return -1; + } + if (yNum) { + return 1; + } + return x < y ? -1 : 1; + } + return 0; +} + +// Newest release of the channel ('latest' or 'beta') as { tag, url }, cached. +// "force" ignores the cache (used by the manual "Check update" button). +// Rejects when GitHub cannot be reached or the answer is not what we expect. +async function getLatestRelease(channel, force) { + const cacheKey = STORAGE_KEY_LATEST + '_' + channel; + const now = Date.now(); + let cached = null; + try { + cached = JSON.parse(storageGet(cacheKey) || 'null'); + } catch (e) { + cached = null; + } + + if (!force && cached && typeof cached.time === 'number') { + if (cached.failed) { + if (now - cached.time < RELEASE_RETRY_DELAY) { + throw new Error('Recent check failed'); + } + } else if (now - cached.time < RELEASE_CACHE_TTL && + VERSION_TAG_REGEX.test(cached.tag) && + GITHUB_RELEASE_URL_REGEX.test(cached.url)) { + return cached; + } + } + + try { + const api = 'https://api.github.com/repos/AdguardTeam/AdGuardHome/releases' + + (channel === 'beta' ? '?per_page=1' : '/latest'); + const ctrl = (typeof AbortController === 'function') ? new AbortController() : null; + const timer = ctrl ? window.setTimeout(function() { ctrl.abort(); }, RELEASE_FETCH_TIMEOUT) : null; + let res; + try { + res = await fetch(api, ctrl ? { signal: ctrl.signal } : undefined); + } finally { + if (timer) { + window.clearTimeout(timer); + } + } + if (!res.ok) { + throw new Error('HTTP ' + res.status); + } + let data = await res.json(); + if (Array.isArray(data)) { + data = data[0]; + } + if (!data || typeof data.tag_name !== 'string' || !VERSION_TAG_REGEX.test(data.tag_name)) { + throw new Error('Invalid version data'); + } + const url = (typeof data.html_url === 'string' && GITHUB_RELEASE_URL_REGEX.test(data.html_url)) + ? data.html_url + : 'https://github.com/AdguardTeam/AdGuardHome/releases/tag/' + data.tag_name; + const entry = { tag: data.tag_name, url: url, time: now }; + storageSet(cacheKey, JSON.stringify(entry)); + return entry; + } catch (e) { + storageSet(cacheKey, JSON.stringify({ failed: true, time: now })); + throw e; + } +} + +// Hint appended to the "Current Version" row; onUpdate runs when "Update" is clicked. +// Built from DOM nodes only (no HTML strings). +function buildVersionHint(release, onUpdate) { + return E('span', { 'id': 'agh-version-hint', 'style': 'margin-left: 12px;' }, [ + E('strong', { 'style': 'color: var(--warn-color-high, #d9822b);' }, + '→ ' + _('New version %s available').replace('%s', release.tag)), + ' ', + E('a', { 'href': release.url, 'target': '_blank', 'rel': 'noopener noreferrer' }, _('Release notes')), + ' | ', + E('a', { + 'href': '#', + 'click': function(ev) { + ev.preventDefault(); + onUpdate(); + } + }, _('Update')) + ]); +} + +// Tighter spacing for the "Current Version" / "Service Status" rows. The themes +// give every row a lot of padding; !important is needed to win over them. +const STATUS_COMPACT_CSS = + '.agh-status-row { margin: 0 !important; padding-top: 2px !important; padding-bottom: 2px !important; min-height: 0 !important; }' + + '.agh-status-row .cbi-value-title, .agh-status-row .cbi-value-field { margin: 0 !important; padding-top: 0 !important; padding-bottom: 0 !important; min-height: 0 !important; line-height: 1.5 !important; }'; + +function findCoreUpdateTabLink(root) { + return root.querySelector('a[href="#cbi-tab-adguardhome-core_update"]') || + root.querySelector('a[href="#core_update"]') || + root.querySelector('[data-tab="core_update"]'); +} + +// The Core Update tab only exists while "Core Update" is ticked in General Settings. +function isCoreUpdateTabEnabled(root) { + const boxes = root.querySelectorAll('input[type="checkbox"]'); + for (let i = 0; i < boxes.length; i++) { + if (boxes[i].id && boxes[i].id.endsWith('.enable_core_update')) { + return boxes[i].checked; + } + } + return false; +} + +// Show the Core Update tab. If the tab is already available just switch to it; +// otherwise switch the toggle on (it lives in sessionStorage) and reload, so the +// tab is created and selected on the next load. +function goToCoreUpdateTab(root) { + if (isCoreUpdateTabEnabled(root)) { + const link = findCoreUpdateTabLink(root); + if (link) { + link.click(); + return; + } + } + try { + sessionStorage.setItem(STORAGE_KEY_CORE, '1'); + sessionStorage.setItem('agh_active_tab', 'core_update'); + } catch (e) {} + window.location.reload(); +} +// ---- END new-release notice -------------------------------------------------- + function getServiceInfo(name) { const fn = rpc.declare({ object: 'service', @@ -244,6 +448,7 @@ return view.extend({ const yamlContent = data[2]; const coreExists = Boolean(version); + const installed = parseVersion(version); let dnsPort = '53'; if (yamlContent) { const portMatches = yamlContent.match(/port:\s*(\d+)/g); @@ -256,6 +461,7 @@ return view.extend({ } const sections = uci.sections('adguardhome', 'adguardhome'); + const savedChannel = (sections.length > 0 && sections[0].core_version === 'beta') ? 'beta' : 'latest'; const map = new form.Map('adguardhome', _('AdGuard Home')); @@ -263,7 +469,7 @@ return view.extend({ statusSect.anonymous = true; statusSect.cfgsections = function() { return ['status_section']; }; - const versionOpt = statusSect.option(form.DummyValue, '_version', _('Version')); + const versionOpt = statusSect.option(form.DummyValue, '_version', _('Current Version')); versionOpt.cfgvalue = function() { return version ? esc(version) @@ -457,30 +663,131 @@ return view.extend({ const rendered = await map.render(); + // Look elements up inside our own tree: it may not be attached to the document + // yet when the background release check finishes. + const byId = function(id) { + return rendered.querySelector('#' + id); + }; + if (sessionStorage.getItem('agh_active_tab') === 'core_update') { sessionStorage.removeItem('agh_active_tab'); window.location.hash = 'core_update'; window.setTimeout(function() { - const tabLink = rendered.querySelector('a[href="#cbi-tab-adguardhome-core_update"]') || - rendered.querySelector('a[href="#core_update"]') || - rendered.querySelector('[data-tab="core_update"]'); + const tabLink = findCoreUpdateTabLink(rendered); if (tabLink) tabLink.click(); }, 50); } + // Compact "Current Version" / "Service Status" rows. + const versionRow = map.findElement('data-field', versionOpt.cbid('status_section')); + const statusRow = map.findElement('data-field', statusOpt.cbid('status_section')); + if (versionRow) versionRow.classList.add('agh-status-row'); + if (statusRow) statusRow.classList.add('agh-status-row'); + rendered.appendChild(E('style', {}, STATUS_COMPACT_CSS)); + const statusNode = map.findElement('data-field', statusOpt.cbid('status_section')); poll.add(updateStatus(statusNode), POLL_INTERVAL); let updatePollId = null; let currentState = 'init'; + // Channel ('latest' or 'beta') the Action button / version hint currently refer to. + let detectedChannel = savedChannel; + function setDetail(node) { - const detail = document.getElementById('btn-agh-detail'); + const detail = byId('btn-agh-detail'); if (detail) { dom.content(detail, node || ''); } } + // The channel selected in the Core Branch dropdown right now (saved one as fallback). + function currentChannel() { + try { + const secId = sections.length > 0 ? sections[0]['.name'] : null; + const v = secId ? coreVersionOpt.formvalue(secId) : null; + if (CORE_VERSIONS.indexOf(v) !== -1) { + return v; + } + } catch (err) {} + return savedChannel; + } + + // "Click to update" state, shared by the manual and the background check. + // release.url is always a github.com/AdguardTeam/AdGuardHome/releases URL (see getLatestRelease). + function showUpdatable(release) { + const btnAction = byId('btn-agh-action'); + const btnForce = byId('btn-agh-force'); + + currentState = 'updatable'; + if (btnAction) { + btnAction.value = _('Click to update'); + btnAction.disabled = false; + } + if (btnForce) { + btnForce.style.display = 'none'; + } + setDetail(E('a', { 'href': release.url, 'target': '_blank', 'rel': 'noopener noreferrer' }, + E('em', { 'style': 'color: red;' }, release.tag))); + } + + // Back to the initial "Check update" state. + function resetToCheck() { + const btnAction = byId('btn-agh-action'); + const btnForce = byId('btn-agh-force'); + + currentState = 'init'; + if (btnAction) { + btnAction.value = _('Check update'); + btnAction.disabled = false; + } + if (btnForce) { + btnForce.style.display = 'none'; + } + setDetail(''); + } + + // Show (release) or remove (null) the new-version hint after the version number. + function setVersionHint(release) { + const field = versionRow + ? (versionRow.querySelector('output') || versionRow.querySelector('.cbi-value-field')) + : null; + if (!field) { + return; + } + const old = field.querySelector('#agh-version-hint'); + if (old) { + old.remove(); + } + if (release) { + field.appendChild(buildVersionHint(release, function() { + goToCoreUpdateTab(rendered); + })); + } + } + + // Background check: if a newer release exists, show the hint and, while the Action + // button is still untouched, go straight to "Click to update". + function detectNewRelease(channel) { + if (!coreExists || !installed) { + return; + } + getLatestRelease(channel).then(function(release) { + if (channel !== detectedChannel) { + return; + } + const latest = parseVersion(release.tag); + if (!latest || compareVersions(latest, installed) <= 0) { + setVersionHint(null); + return; + } + setVersionHint(release); + if (currentState === 'init') { + showUpdatable(release); + } + }).catch(function() {}); + } + function startLogPolling() { if (updatePollId) clearInterval(updatePollId); @@ -492,8 +799,8 @@ return view.extend({ let targetPercent = 0; const pollAction = function() { - const btnAction = document.getElementById('btn-agh-action'); - const btnForce = document.getElementById('btn-agh-force'); + const btnAction = byId('btn-agh-action'); + const btnForce = byId('btn-agh-force'); if (currentState === 'updating') { if (displayPercent < targetPercent) { @@ -588,8 +895,8 @@ return view.extend({ } function triggerCheck() { - const btnAction = document.getElementById('btn-agh-action'); - const btnForce = document.getElementById('btn-agh-force'); + const btnAction = byId('btn-agh-action'); + const btnForce = byId('btn-agh-force'); currentState = 'checking'; if (btnAction) { @@ -598,66 +905,43 @@ return view.extend({ } setDetail(''); - fetch('https://api.github.com/repos/AdguardTeam/AdGuardHome/releases/latest') - .then(function(res) { - if (!res.ok) { - throw new Error('HTTP ' + res.status); - } - return res.json(); - }) - .then(function(data) { - const remoteVersion = (data && typeof data.tag_name === 'string') ? data.tag_name : null; - if (!remoteVersion || !VERSION_TAG_REGEX.test(remoteVersion)) { - throw new Error('Invalid version data'); - } - const cleanLocal = coreExists ? version.replace(/^v/, '') : ''; - const cleanRemote = remoteVersion.replace(/^v/, ''); + const channel = currentChannel(); + getLatestRelease(channel, true).then(function(release) { + detectedChannel = channel; + const latest = parseVersion(release.tag); - if (!coreExists || cleanLocal !== cleanRemote) { - currentState = 'updatable'; - if (btnAction) { - btnAction.value = _('Click to update'); - btnAction.disabled = false; - } - if (btnForce) { - btnForce.style.display = 'none'; - } - - // Text only; link only if it points at the project's own releases. - let node = E('em', { 'style': 'color: red;' }, remoteVersion); - if (typeof data.html_url === 'string' && GITHUB_RELEASE_URL_REGEX.test(data.html_url)) { - node = E('a', { 'href': data.html_url, 'target': '_blank', 'rel': 'noopener noreferrer' }, node); - } - setDetail(node); - } else { - currentState = 'latest'; - if (btnAction) { - btnAction.value = _('It is the latest version'); - btnAction.disabled = true; - } - if (btnForce) { - btnForce.style.display = 'inline-block'; - btnForce.disabled = false; - } - setDetail(''); - } - }) - .catch(function() { - currentState = 'check_failed'; + if (!coreExists || !installed || !latest || compareVersions(latest, installed) > 0) { + showUpdatable(release); + setVersionHint(coreExists ? release : null); + } else { + currentState = 'latest'; if (btnAction) { - btnAction.value = _('Retry'); - btnAction.disabled = false; + btnAction.value = _('It is the latest version'); + btnAction.disabled = true; } if (btnForce) { - btnForce.style.display = 'none'; + btnForce.style.display = 'inline-block'; + btnForce.disabled = false; } - setDetail(E('em', { 'style': 'color: red;' }, _('Check failed'))); - }); + setDetail(''); + setVersionHint(null); + } + }).catch(function() { + currentState = 'check_failed'; + if (btnAction) { + btnAction.value = _('Retry'); + btnAction.disabled = false; + } + if (btnForce) { + btnForce.style.display = 'none'; + } + setDetail(E('em', { 'style': 'color: red;' }, _('Check failed'))); + }); } function triggerUpdate(isForce) { - const btnAction = document.getElementById('btn-agh-action'); - const btnForce = document.getElementById('btn-agh-force'); + const btnAction = byId('btn-agh-action'); + const btnForce = byId('btn-agh-force'); currentState = 'updating'; if (btnAction) { @@ -762,7 +1046,7 @@ return view.extend({ const isCore = results[0]; const isError = results[1]; if (isCore || isError) { - const btnAction = document.getElementById('btn-agh-action'); + const btnAction = byId('btn-agh-action'); currentState = 'updating'; if (btnAction) { btnAction.value = _('Updating...') + ' 0%'; @@ -772,6 +1056,25 @@ return view.extend({ } }); + // Background check for a newer release: it runs after the page is shown, never + // delays it, and stays silent if GitHub cannot be reached. + detectNewRelease(detectedChannel); + + // Changing the Core Branch dropdown invalidates what the Action button shows. + rendered.addEventListener('change', function() { + if (['init', 'updatable', 'latest', 'check_failed'].indexOf(currentState) === -1) { + return; + } + const channel = currentChannel(); + if (channel === detectedChannel) { + return; + } + detectedChannel = channel; + resetToCheck(); + setVersionHint(null); + detectNewRelease(channel); + }); + return rendered; } }); diff --git a/luci-app-adguardhome/patches/02_acl.patch b/luci-app-adguardhome/patches/02_acl.patch index 5786392cb..ee0081e78 100644 --- a/luci-app-adguardhome/patches/02_acl.patch +++ b/luci-app-adguardhome/patches/02_acl.patch @@ -1,54 +1,39 @@ -From 883995b0d027fcd32fb9ba4ff24d607e9c176ee2 Mon Sep 17 00:00:00 2001 -From: Jackie264 <125248570+Jackie264@users.noreply.github.com> -Date: Thu, 28 May 2026 10:14:54 +0800 -Subject: [PATCH] Update luci-app-adguardhome.json +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Jackie Han +Date: Wed, 4 Nov 2026 10:22:07 +0800 +Subject: [PATCH] luci-app-adguardhome: ACL for the core update flow +Allow the update log, the update flag files and update_core.sh, including +update_core_done (stat to detect completion, write to remove it). --- - .../rpcd/acl.d/luci-app-adguardhome.json | 36 +++++++++++-------- - 1 file changed, 21 insertions(+), 15 deletions(-) + root/usr/share/rpcd/acl.d/luci-app-adguardhome.json | 10 ++++++++++ + 1 file changed, 10 insertions(+) diff --git a/root/usr/share/rpcd/acl.d/luci-app-adguardhome.json b/root/usr/share/rpcd/acl.d/luci-app-adguardhome.json -index 748778a..33ba1fd 100644 +index 748778a..33862ec 100644 --- a/root/usr/share/rpcd/acl.d/luci-app-adguardhome.json +++ b/root/usr/share/rpcd/acl.d/luci-app-adguardhome.json -@@ -1,17 +1,25 @@ - { -- "luci-app-adguardhome": { -- "description": "Grant permissions for the AdGuard Home LuCI app", -- "read": { -- "file": { -- "/usr/bin/AdGuardHome --version": [ "exec" ] -- }, -- "ubus": { -- "service": [ "list" ] -- }, -- "uci": [ "adguardhome" ] -- }, -- "write": { -- "uci": [ "adguardhome" ] -- } -- } -+ "luci-app-adguardhome": { -+ "description": "Grant permissions for the AdGuard Home LuCI app", -+ "read": { -+ "file": { +@@ -3,6 +3,12 @@ + "description": "Grant permissions for the AdGuard Home LuCI app", + "read": { + "file": { + "/tmp/AdGuardHome_update.log": [ "read" ], + "/var/run/update_core": [ "stat" ], ++ "/var/run/update_core_done": [ "stat" ], + "/var/run/update_core_error": [ "stat" ], + "/var/run/AdGredir": [ "read" ], + "/var/run/AdGserverdis": [ "read" ], -+ "/usr/bin/AdGuardHome --version": [ "exec" ] -+ }, -+ "ubus": { -+ "service": [ "list" ] -+ }, -+ "uci": [ "adguardhome" ] -+ }, -+ "write": { + "/usr/bin/AdGuardHome --version": [ "exec" ] + }, + "ubus": { +@@ -11,6 +17,10 @@ + "uci": [ "adguardhome" ] + }, + "write": { + "file": { -+ "/usr/share/AdGuardHome/update_core.sh": [ "exec" ] ++ "/usr/share/AdGuardHome/update_core.sh": [ "exec" ], ++ "/var/run/update_core_done": [ "write" ] + }, -+ "uci": [ "adguardhome" ] -+ } -+ } - } + "uci": [ "adguardhome" ] + } + } diff --git a/luci-app-adguardhome/patches/03_config_js.patch b/luci-app-adguardhome/patches/03_config_js.patch index 3564d4be7..b58d586f4 100644 --- a/luci-app-adguardhome/patches/03_config_js.patch +++ b/luci-app-adguardhome/patches/03_config_js.patch @@ -1,15 +1,18 @@ -From e0b6eccaf56a0b15a09c002e868f153032af5420 Mon Sep 17 00:00:00 2001 +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 From: Jackie Han -Date: Sun, 25 Oct 2026 15:05:05 +0800 -Subject: [PATCH] Enhance config.js with validation and UI improvements +Date: Wed, 4 Nov 2026 10:13:35 +0800 +Subject: [PATCH] luci-app-adguardhome: config.js hardening, update_source and + new-version hint -Added validation functions for paths and passwords, improved error handling, and updated UI elements for better user experience. +Hardens the LuCI page, replaces update_url with update_source, and shows +a new-core-version hint with a one-click path to the Core Update tab. +The Service Status pill doubles as the Enable switch, next to the version. --- - .../resources/view/adguardhome/config.js | 744 +++++++++++++++--- - 1 file changed, 630 insertions(+), 114 deletions(-) + .../resources/view/adguardhome/config.js | 1207 +++++++++++++++-- + 1 file changed, 1076 insertions(+), 131 deletions(-) diff --git a/htdocs/luci-static/resources/view/adguardhome/config.js b/htdocs/luci-static/resources/view/adguardhome/config.js -index 6dc2d45..b0f0e06 100644 +index 6dc2d45..07aa5cc 100644 --- a/htdocs/luci-static/resources/view/adguardhome/config.js +++ b/htdocs/luci-static/resources/view/adguardhome/config.js @@ -5,6 +5,7 @@ @@ -20,7 +23,7 @@ index 6dc2d45..b0f0e06 100644 'require view'; const DEFAULT_CONFIG_FILE = '/etc/adguardhome/adguardhome.yaml'; -@@ -18,12 +19,56 @@ const DEFAULT_GOMEMLIMIT = '0'; +@@ -18,12 +19,292 @@ const DEFAULT_GOMEMLIMIT = '0'; const PATH_REGEX = new RegExp('^/etc(/[^/]+)?/?$'); @@ -42,8 +45,6 @@ index 6dc2d45..b0f0e06 100644 -const RUNNING_SPAN = `${_('Running')}`; -const NOT_RUNNING_SPAN = `${_('Not running')}`; -+const RUNNING_SPAN = `${_('Running')}`; -+const NOT_RUNNING_SPAN = `${_('Not running')}`; const STORAGE_KEY = 'luci-app-adguardhome'; +const STORAGE_KEY_CORE = 'luci-app-adguardhome_core_update'; @@ -76,10 +77,248 @@ index 6dc2d45..b0f0e06 100644 +function hasDotDotSegment(value) { + return value.split('/').indexOf('..') !== -1; +} ++ ++// ---- BEGIN new-release notice ------------------------------------------------ ++// AdGuard Home's own /control/version.json needs HTTP basic auth (this page only ++// ever holds the password hash) and is served on another port, so the page asks ++// GitHub directly: the same source as the "Check update" button and update_core.sh. ++// The answer is cached in localStorage so opening the page does not cost an API ++// request every time (GitHub allows only 60 unauthenticated requests per hour). ++const STORAGE_KEY_LATEST = 'luci-app-adguardhome_latest_release'; ++const RELEASE_CACHE_TTL = 6 * 60 * 60 * 1000; // after a successful check ++const RELEASE_RETRY_DELAY = 30 * 60 * 1000; // after a failed check ++const RELEASE_FETCH_TIMEOUT = 8000; ++ ++function storageGet(key) { ++ try { ++ return window.localStorage.getItem(key); ++ } catch (e) { ++ return null; ++ } ++} ++ ++function storageSet(key, value) { ++ try { ++ window.localStorage.setItem(key, value); ++ } catch (e) {} ++} ++ ++// "v0.107.57-b.2" -> { nums: [0, 107, 57], pre: ['b', '2'] }, or null ++function parseVersion(str) { ++ const m = /v?(\d+)\.(\d+)\.(\d+)(?:-([A-Za-z0-9.]+))?/.exec(String(str || '')); ++ if (!m) { ++ return null; ++ } ++ return { nums: [+m[1], +m[2], +m[3]], pre: m[4] ? m[4].split('.') : [] }; ++} ++ ++function formatVersion(v) { ++ return 'v' + v.nums.join('.') + (v.pre.length ? '-' + v.pre.join('.') : ''); ++} ++ ++// Comparator result (<0, 0, >0). A pre-release sorts below its release. ++function compareVersions(a, b) { ++ for (let i = 0; i < 3; i++) { ++ if (a.nums[i] !== b.nums[i]) { ++ return a.nums[i] - b.nums[i]; ++ } ++ } ++ if (!a.pre.length && !b.pre.length) { ++ return 0; ++ } ++ if (!a.pre.length) { ++ return 1; ++ } ++ if (!b.pre.length) { ++ return -1; ++ } ++ const n = Math.max(a.pre.length, b.pre.length); ++ for (let i = 0; i < n; i++) { ++ const x = a.pre[i]; ++ const y = b.pre[i]; ++ if (x === undefined) { ++ return -1; ++ } ++ if (y === undefined) { ++ return 1; ++ } ++ if (x === y) { ++ continue; ++ } ++ const xNum = /^\d+$/.test(x); ++ const yNum = /^\d+$/.test(y); ++ if (xNum && yNum) { ++ return +x - +y; ++ } ++ if (xNum) { ++ return -1; ++ } ++ if (yNum) { ++ return 1; ++ } ++ return x < y ? -1 : 1; ++ } ++ return 0; ++} ++ ++// Newest release of the channel ('latest' or 'beta') as { tag, url }, cached. ++// "force" ignores the cache (used by the manual "Check update" button). ++// Rejects when GitHub cannot be reached or the answer is not what we expect. ++async function getLatestRelease(channel, force) { ++ const cacheKey = STORAGE_KEY_LATEST + '_' + channel; ++ const now = Date.now(); ++ let cached = null; ++ try { ++ cached = JSON.parse(storageGet(cacheKey) || 'null'); ++ } catch (e) { ++ cached = null; ++ } ++ ++ if (!force && cached && typeof cached.time === 'number') { ++ if (cached.failed) { ++ if (now - cached.time < RELEASE_RETRY_DELAY) { ++ throw new Error('Recent check failed'); ++ } ++ } else if (now - cached.time < RELEASE_CACHE_TTL && ++ VERSION_TAG_REGEX.test(cached.tag) && ++ GITHUB_RELEASE_URL_REGEX.test(cached.url)) { ++ return cached; ++ } ++ } ++ ++ try { ++ const api = 'https://api.github.com/repos/AdguardTeam/AdGuardHome/releases' + ++ (channel === 'beta' ? '?per_page=1' : '/latest'); ++ const ctrl = (typeof AbortController === 'function') ? new AbortController() : null; ++ const timer = ctrl ? window.setTimeout(function() { ctrl.abort(); }, RELEASE_FETCH_TIMEOUT) : null; ++ let res; ++ try { ++ res = await fetch(api, ctrl ? { signal: ctrl.signal } : undefined); ++ } finally { ++ if (timer) { ++ window.clearTimeout(timer); ++ } ++ } ++ if (!res.ok) { ++ throw new Error('HTTP ' + res.status); ++ } ++ let data = await res.json(); ++ if (Array.isArray(data)) { ++ data = data[0]; ++ } ++ if (!data || typeof data.tag_name !== 'string' || !VERSION_TAG_REGEX.test(data.tag_name)) { ++ throw new Error('Invalid version data'); ++ } ++ const url = (typeof data.html_url === 'string' && GITHUB_RELEASE_URL_REGEX.test(data.html_url)) ++ ? data.html_url ++ : 'https://github.com/AdguardTeam/AdGuardHome/releases/tag/' + data.tag_name; ++ const entry = { tag: data.tag_name, url: url, time: now }; ++ storageSet(cacheKey, JSON.stringify(entry)); ++ return entry; ++ } catch (e) { ++ storageSet(cacheKey, JSON.stringify({ failed: true, time: now })); ++ throw e; ++ } ++} ++ ++// Hint appended to the "Current Version" row. The whole text is the button: a click ++// runs onUpdate (go to the Core Update tab). A span, not a link, so the theme's link ++// colour / underline never applies. Built from DOM nodes only (no HTML strings). ++function buildVersionHint(release, onUpdate) { ++ return E('span', { ++ 'id': 'agh-version-hint', ++ 'role': 'button', ++ 'tabindex': '0', ++ 'title': _('Go to Core Update'), ++ 'style': 'margin-left: 12px; cursor: pointer;', ++ 'click': function(ev) { ++ ev.preventDefault(); ++ onUpdate(); ++ }, ++ 'keydown': function(ev) { ++ if (ev.key === 'Enter' || ev.key === ' ') { ++ ev.preventDefault(); ++ onUpdate(); ++ } ++ } ++ }, E('strong', { 'style': 'color: var(--warn-color-high, #d9822b);' }, ++ '\u2192 ' + _('New version %s available').replace('%s', release.tag))); ++} ++ ++// Header card above the settings: one line with the Service Status pill (which is also ++// the Enable switch) and the Current Version. The pill follows the style of the Main ++// Switch of luci-app-bypass. ++const HEAD_CSS = ++ '.agh-head { --agh-on-fg:#1a7f37; --agh-on-bg:#dafbe1; --agh-warn-fg:#9a6700; --agh-warn-bg:#fff8c5; --agh-off-fg:#82071e; --agh-off-bg:#ffebe9; display:flex; flex-direction:column; gap:.5rem; margin:.5rem 0 1rem; padding:.7rem 1rem; border-radius:var(--radius-card, 6px); background:var(--surface-raised, rgba(128,128,128,.06)); box-shadow:0 4px 8px rgba(0,0,0,.03); }' + ++ ':root[data-argon-mode="dark"] .agh-head, :root[data-darkmode="true"] .agh-head { --agh-on-fg:#7ee2a0; --agh-on-bg:rgba(46,160,67,.22); --agh-warn-fg:#e3b341; --agh-warn-bg:rgba(187,128,9,.22); --agh-off-fg:#ffa9ae; --agh-off-bg:rgba(248,81,73,.18); }' + ++ '.agh-head-row { display:flex; align-items:center; flex-wrap:wrap; gap:.4rem 2rem; }' + ++ '.agh-head-item { display:inline-flex; align-items:center; flex-wrap:wrap; gap:.25rem .75rem; line-height:1.5; }' + ++ '.agh-head-note { line-height:1.5; }' + ++ '.agh-switch { display:inline-flex; align-items:center; gap:.4rem; height:auto; min-height:0; line-height:1.4; padding:.25rem .8rem; margin:0; border-radius:999px; font-weight:600; font-size:.9rem; border:0; box-shadow:none; cursor:pointer; transition:filter .15s; }' + ++ '.agh-switch:hover { filter:brightness(.95); }' + ++ '.agh-switch::before { content:""; width:.55rem; height:.55rem; border-radius:50%; background:currentColor; }' + ++ '.agh-switch.on { color:var(--agh-on-fg); background:var(--agh-on-bg); }' + ++ '.agh-switch.warn { color:var(--agh-warn-fg); background:var(--agh-warn-bg); }' + ++ '.agh-switch.off { color:var(--agh-off-fg); background:var(--agh-off-bg); }'; ++ ++// The clickable of the Core Update tab in the tab menu (not the tab pane, which ++// carries the same data-tab value). ++function findCoreUpdateTabLink(root) { ++ return root.querySelector('.cbi-tabmenu li[data-tab="core_update"] a') || ++ root.querySelector('li[data-tab="core_update"] a') || ++ root.querySelector('a[href="#cbi-tab-adguardhome-core_update"]') || ++ root.querySelector('a[href="#core_update"]'); ++} ++ ++// Click the tab as soon as it exists and is visible; the tab menu is built and ++// shown/hidden (depends) shortly after render, so retry for a couple of seconds. ++function activateCoreUpdateTab(root, tries) { ++ const link = findCoreUpdateTabLink(root); ++ const li = link ? link.parentNode : null; ++ // LuCI marks every inactive tab 'cbi-tab-disabled', so that class means nothing here; ++ // a tab that is not available (no visible option yet) has display:none. ++ const hidden = li && li.style.display === 'none'; ++ if (link && !hidden) { ++ link.click(); ++ return; ++ } ++ if ((tries || 0) < 40) { ++ window.setTimeout(function() { activateCoreUpdateTab(root, (tries || 0) + 1); }, 50); ++ } ++} ++ ++// The Core Update tab only exists while "Core Update" is ticked in General Settings. ++function isCoreUpdateTabEnabled(root) { ++ const boxes = root.querySelectorAll('input[type="checkbox"]'); ++ for (let i = 0; i < boxes.length; i++) { ++ if (boxes[i].id && boxes[i].id.endsWith('.enable_core_update')) { ++ return boxes[i].checked; ++ } ++ } ++ return false; ++} ++ ++// Show the Core Update tab. If the tab is already available just switch to it; ++// otherwise switch the toggle on (it lives in sessionStorage) and reload, so the ++// tab is created and selected on the next load. ++function goToCoreUpdateTab(root) { ++ if (isCoreUpdateTabEnabled(root)) { ++ if (findCoreUpdateTabLink(root)) { ++ activateCoreUpdateTab(root, 0); ++ return; ++ } ++ } ++ try { ++ sessionStorage.setItem(STORAGE_KEY_CORE, '1'); ++ sessionStorage.setItem('agh_active_tab', 'core_update'); ++ } catch (e) {} ++ window.location.reload(); ++} ++// ---- END new-release notice -------------------------------------------------- function getServiceInfo(name) { const fn = rpc.declare({ -@@ -32,7 +77,9 @@ function getServiceInfo(name) { +@@ -32,7 +313,9 @@ function getServiceInfo(name) { params: ['name'], expect: { [name]: { instances: { [name]: {} }}}, }); @@ -90,7 +329,7 @@ index 6dc2d45..b0f0e06 100644 } const getAGHServiceInfo = getServiceInfo('adguardhome'); -@@ -40,8 +87,8 @@ const getAGHServiceInfo = getServiceInfo('adguardhome'); +@@ -40,18 +323,14 @@ const getAGHServiceInfo = getServiceInfo('adguardhome'); async function getStatus() { try { const res = await getAGHServiceInfo(); @@ -101,7 +340,17 @@ index 6dc2d45..b0f0e06 100644 } catch (e) { console.error(e); return false; -@@ -61,26 +108,40 @@ async function getVersion() { + } + } + +-function getStatusValue(isRunning) { +- return isRunning ? RUNNING_SPAN : NOT_RUNNING_SPAN; +-} +- + async function getVersion() { + try { + const res = await fs.exec('/usr/bin/AdGuardHome', ['--version']); +@@ -61,26 +340,30 @@ async function getVersion() { return version; } catch (e) { console.error(e); @@ -110,19 +359,14 @@ index 6dc2d45..b0f0e06 100644 } } - function updateStatus(node) { +-function updateStatus(node) { - const output = node?.querySelector('output'); -+ const output = node ? node.querySelector('output') : null; - return output +- return output - ? async () => { -+ ? async function() { - const isRunning = await getStatus(); - dom.content(output, getStatusValue(isRunning)); - } +- const isRunning = await getStatus(); +- dom.content(output, getStatusValue(isRunning)); +- } - : () => {}; -+ : function() {}; -+} -+ +function validateAbsolutePath(value) { + if (!value.startsWith('/')) { + return _('Path must be absolute.'); @@ -148,7 +392,7 @@ index 6dc2d45..b0f0e06 100644 } if (value.endsWith('/')) { return _('Path must not end with a slash.'); -@@ -95,167 +156,622 @@ function validateWorkDir(_unused, value) { +@@ -95,167 +378,829 @@ function validateWorkDir(_unused, value) { if (value == null || value === '') { return true; } @@ -238,12 +482,17 @@ index 6dc2d45..b0f0e06 100644 }, - async render([isRunning, version]) { +- const map = new form.Map('adguardhome', _('AdGuard Home')); + render: async function(data) { + const isRunning = data[0]; + const version = data[1]; + const yamlContent = data[2]; -+ + +- const statusSect = map.section(form.TypedSection, 'status'); +- statusSect.anonymous = true; +- statusSect.cfgsections = () => ['status_section']; + const coreExists = Boolean(version); ++ const installed = parseVersion(version); + let dnsPort = '53'; + if (yamlContent) { + const portMatches = yamlContent.match(/port:\s*(\d+)/g); @@ -254,29 +503,16 @@ index 6dc2d45..b0f0e06 100644 + } + } + } -+ -+ const sections = uci.sections('adguardhome', 'adguardhome'); -+ - const map = new form.Map('adguardhome', _('AdGuard Home')); - const statusSect = map.section(form.TypedSection, 'status'); - statusSect.anonymous = true; -- statusSect.cfgsections = () => ['status_section']; -+ statusSect.cfgsections = function() { return ['status_section']; }; - - const versionOpt = statusSect.option(form.DummyValue, '_version', _('Version')); +- const versionOpt = statusSect.option(form.DummyValue, '_version', _('Version')); - versionOpt.cfgvalue = () => version; -+ versionOpt.cfgvalue = function() { -+ return version -+ ? esc(version) -+ : '' + esc(_('Not installed')) + ''; -+ }; -+ versionOpt.rawhtml = true; ++ const sections = uci.sections('adguardhome', 'adguardhome'); ++ const savedChannel = (sections.length > 0 && sections[0].core_version === 'beta') ? 'beta' : 'latest'; - const statusOpt = statusSect.option(form.DummyValue, '_status', _('Service Status')); - statusOpt.rawhtml = true; +- const statusOpt = statusSect.option(form.DummyValue, '_status', _('Service Status')); +- statusOpt.rawhtml = true; - statusOpt.cfgvalue = () => getStatusValue(isRunning); -+ statusOpt.cfgvalue = function() { return getStatusValue(isRunning); }; ++ const map = new form.Map('adguardhome', _('AdGuard Home')); const mainSect = map.section(form.TypedSection, 'adguardhome'); mainSect.anonymous = true; @@ -311,9 +547,6 @@ index 6dc2d45..b0f0e06 100644 + const enabledOpt = mainSect.taboption('general', form.Flag, 'enabled', _('Enable')); + enabledOpt.default = '0'; + enabledOpt.rmempty = false; -+ if (!coreExists) { -+ enabledOpt.description = '' + esc(_('Core binary not found. Enable the service to trigger an automatic download.')) + ''; -+ } + + const configFileOpt = mainSect.taboption('general', form.Value, 'config_file', _('Configuration file')); configFileOpt.placeholder = DEFAULT_CONFIG_FILE; @@ -555,30 +788,251 @@ index 6dc2d45..b0f0e06 100644 + const rendered = await map.render(); +- const statusNode = map.findElement('data-field', statusOpt.cbid('status_section')); +- poll.add(updateStatus(statusNode), POLL_INTERVAL); ++ // Look elements up inside our own tree: it may not be attached to the document ++ // yet when the background release check finishes. ++ const byId = function(id) { ++ return rendered.querySelector('#' + id); ++ }; ++ + if (sessionStorage.getItem('agh_active_tab') === 'core_update') { + sessionStorage.removeItem('agh_active_tab'); + window.location.hash = 'core_update'; -+ window.setTimeout(function() { -+ const tabLink = rendered.querySelector('a[href="#cbi-tab-adguardhome-core_update"]') || -+ rendered.querySelector('a[href="#core_update"]') || -+ rendered.querySelector('[data-tab="core_update"]'); -+ if (tabLink) tabLink.click(); -+ }, 50); ++ activateCoreUpdateTab(rendered, 0); + } + - const statusNode = map.findElement('data-field', statusOpt.cbid('status_section')); - poll.add(updateStatus(statusNode), POLL_INTERVAL); - ++ // ---- Header: Service Status pill (= Enable switch) + Current Version ----------- ++ // The pill drives the (hidden) "Enable" checkbox of the form, so saving and applying ++ // work exactly as before. LuCI re-renders the whole form after "Save" and throws away ++ // everything we added, so decorate() builds the header again after every render. ++ const enableSecId = sections.length > 0 ? sections[0]['.name'] : null; ++ ++ function readEnabled() { ++ const el = enableSecId ? enabledOpt.getUIElement(enableSecId) : null; ++ if (el) { ++ return el.getValue() === '1'; ++ } ++ return sections.length > 0 && sections[0].enabled === '1'; ++ } ++ ++ // The pill shows the live service state and, when clicked, flips the Enable setting. ++ let running = Boolean(isRunning); ++ let enableSwitch = null; ++ let versionField = null; ++ let head = null; ++ ++ function paintEnableSwitch() { ++ if (!enableSwitch) { ++ return; ++ } ++ const enabled = readEnabled(); ++ let cls, text, hint; ++ ++ if (running && enabled) { ++ cls = 'on'; ++ text = _('Running'); ++ hint = _('Click to disable'); ++ } else if (running) { ++ cls = 'warn'; ++ text = _('Running') + ' \u00b7 ' + _('Disabled'); ++ hint = _('Will stop after "Save & Apply". Click to keep it enabled.'); ++ } else if (enabled) { ++ cls = 'warn'; ++ text = _('Not running'); ++ hint = _('Enabled but not running. Click "Save & Apply" to start it, or click to disable.'); ++ } else { ++ cls = 'off'; ++ text = _('Disabled'); ++ hint = _('Click to enable'); ++ } ++ enableSwitch.className = 'agh-switch ' + cls; ++ dom.content(enableSwitch, text); ++ enableSwitch.title = hint + ' - ' + _('Apply with "Save & Apply"'); ++ } ++ ++ // Line the "Service Status" label up with the text of the first tab ("General ++ // Settings") below. The offset depends on the theme, so measure it once the page ++ // is laid out (and again when the window is resized). ++ function alignHead(tries) { ++ const box = head; ++ const tab = rendered.querySelector('.cbi-tabmenu li a'); ++ let left = 0; ++ if (!box) { ++ return; ++ } ++ if (tab && tab.getBoundingClientRect().width > 0) { ++ const range = document.createRange(); ++ range.selectNodeContents(tab); ++ left = range.getBoundingClientRect().left; ++ } ++ if (left > 0 && box.getBoundingClientRect().width > 0) { ++ box.style.paddingLeft = '0px'; ++ const pad = left - box.getBoundingClientRect().left; ++ box.style.paddingLeft = Math.max(0, Math.round(pad)) + 'px'; ++ } else if (tries < 40) { ++ window.setTimeout(function() { if (box === head) alignHead(tries + 1); }, 50); ++ } ++ } ++ ++ function decorate() { ++ const enableRow = enableSecId ? map.findElement('data-field', enabledOpt.cbid(enableSecId)) : null; ++ if (enableRow) { ++ enableRow.style.display = 'none'; ++ } ++ ++ enableSwitch = E('button', { ++ 'type': 'button', ++ 'click': function(ev) { ++ ev.preventDefault(); ++ const el = enableSecId ? enabledOpt.getUIElement(enableSecId) : null; ++ if (el) { ++ el.setValue(readEnabled() ? '0' : '1'); ++ } ++ paintEnableSwitch(); ++ } ++ }); ++ paintEnableSwitch(); ++ ++ versionField = E('span', {}, ++ coreExists ++ ? version ++ : E('span', { 'style': 'color: var(--error-color-high); font-weight: bold;' }, _('Not installed'))); ++ ++ const headRows = [ ++ E('div', { 'class': 'agh-head-row' }, [ ++ E('span', { 'class': 'agh-head-item' }, [ E('span', {}, _('Service Status')), enableSwitch ]), ++ E('span', { 'class': 'agh-head-item' }, [ E('span', {}, _('Current Version')), versionField ]) ++ ]) ++ ]; ++ if (!coreExists) { ++ headRows.push(E('div', { 'class': 'agh-head-note', 'style': 'color: var(--error-color-high); font-weight: bold;' }, ++ _('Core binary not found. Enable the service to trigger an automatic download.'))); ++ } ++ head = E('div', { 'class': 'agh-head' }, headRows); ++ ++ const title = rendered.querySelector('h2'); ++ rendered.insertBefore(head, title ? title.nextSibling : rendered.firstChild); ++ rendered.appendChild(E('style', {}, HEAD_CSS)); ++ alignHead(0); ++ } ++ ++ decorate(); ++ window.addEventListener('resize', function() { alignHead(40); }); ++ ++ // After "Save" LuCI renders the form again into the same root element: add the ++ // header back and bring the (also re-created) update controls up to date. ++ let afterRerender = null; ++ const origRenderContents = map.renderContents; ++ map.renderContents = function() { ++ return Promise.resolve(origRenderContents.apply(this, arguments)).then(function(res) { ++ if (afterRerender) { ++ afterRerender(); ++ } ++ return res; ++ }); ++ }; ++ ++ poll.add(async function() { ++ running = Boolean(await getStatus()); ++ paintEnableSwitch(); ++ }, POLL_INTERVAL); ++ + let updatePollId = null; + let currentState = 'init'; + ++ // Channel ('latest' or 'beta') the Action button / version hint currently refer to. ++ let detectedChannel = savedChannel; ++ + function setDetail(node) { -+ const detail = document.getElementById('btn-agh-detail'); ++ const detail = byId('btn-agh-detail'); + if (detail) { + dom.content(detail, node || ''); + } + } + ++ // The channel selected in the Core Branch dropdown right now (saved one as fallback). ++ function currentChannel() { ++ try { ++ const secId = sections.length > 0 ? sections[0]['.name'] : null; ++ const v = secId ? coreVersionOpt.formvalue(secId) : null; ++ if (CORE_VERSIONS.indexOf(v) !== -1) { ++ return v; ++ } ++ } catch (err) {} ++ return savedChannel; ++ } ++ ++ // "Click to update" state, shared by the manual and the background check. ++ // release.url is always a github.com/AdguardTeam/AdGuardHome/releases URL (see getLatestRelease). ++ function showUpdatable(release) { ++ const btnAction = byId('btn-agh-action'); ++ const btnForce = byId('btn-agh-force'); ++ ++ currentState = 'updatable'; ++ if (btnAction) { ++ btnAction.value = _('Click to update'); ++ btnAction.disabled = false; ++ } ++ if (btnForce) { ++ btnForce.style.display = 'none'; ++ } ++ setDetail(E('a', { 'href': release.url, 'target': '_blank', 'rel': 'noopener noreferrer' }, ++ E('em', { 'style': 'color: red;' }, release.tag))); ++ } ++ ++ // Back to the initial "Check update" state. ++ function resetToCheck() { ++ const btnAction = byId('btn-agh-action'); ++ const btnForce = byId('btn-agh-force'); ++ ++ currentState = 'init'; ++ if (btnAction) { ++ btnAction.value = _('Check update'); ++ btnAction.disabled = false; ++ } ++ if (btnForce) { ++ btnForce.style.display = 'none'; ++ } ++ setDetail(''); ++ } ++ ++ // Show (release) or remove (null) the new-version hint after the version number. ++ function setVersionHint(release) { ++ const field = versionField; ++ const old = field.querySelector('#agh-version-hint'); ++ if (old) { ++ old.remove(); ++ } ++ if (release) { ++ field.appendChild(buildVersionHint(release, function() { ++ goToCoreUpdateTab(rendered); ++ })); ++ } ++ } ++ ++ // Background check: if a newer release exists, show the hint and, while the Action ++ // button is still untouched, go straight to "Click to update". ++ function detectNewRelease(channel) { ++ if (!coreExists || !installed) { ++ return; ++ } ++ getLatestRelease(channel).then(function(release) { ++ if (channel !== detectedChannel) { ++ return; ++ } ++ const latest = parseVersion(release.tag); ++ if (!latest || compareVersions(latest, installed) <= 0) { ++ setVersionHint(null); ++ return; ++ } ++ setVersionHint(release); ++ if (currentState === 'init') { ++ showUpdatable(release); ++ } ++ }).catch(function() {}); ++ } ++ + function startLogPolling() { + if (updatePollId) clearInterval(updatePollId); + @@ -590,8 +1044,8 @@ index 6dc2d45..b0f0e06 100644 + let targetPercent = 0; + + const pollAction = function() { -+ const btnAction = document.getElementById('btn-agh-action'); -+ const btnForce = document.getElementById('btn-agh-force'); ++ const btnAction = byId('btn-agh-action'); ++ const btnForce = byId('btn-agh-force'); + + if (currentState === 'updating') { + if (displayPercent < targetPercent) { @@ -686,8 +1140,8 @@ index 6dc2d45..b0f0e06 100644 + } + + function triggerCheck() { -+ const btnAction = document.getElementById('btn-agh-action'); -+ const btnForce = document.getElementById('btn-agh-force'); ++ const btnAction = byId('btn-agh-action'); ++ const btnForce = byId('btn-agh-force'); + + currentState = 'checking'; + if (btnAction) { @@ -696,66 +1150,43 @@ index 6dc2d45..b0f0e06 100644 + } + setDetail(''); + -+ fetch('https://api.github.com/repos/AdguardTeam/AdGuardHome/releases/latest') -+ .then(function(res) { -+ if (!res.ok) { -+ throw new Error('HTTP ' + res.status); -+ } -+ return res.json(); -+ }) -+ .then(function(data) { -+ const remoteVersion = (data && typeof data.tag_name === 'string') ? data.tag_name : null; -+ if (!remoteVersion || !VERSION_TAG_REGEX.test(remoteVersion)) { -+ throw new Error('Invalid version data'); -+ } -+ const cleanLocal = coreExists ? version.replace(/^v/, '') : ''; -+ const cleanRemote = remoteVersion.replace(/^v/, ''); ++ const channel = currentChannel(); ++ getLatestRelease(channel, true).then(function(release) { ++ detectedChannel = channel; ++ const latest = parseVersion(release.tag); + -+ if (!coreExists || cleanLocal !== cleanRemote) { -+ currentState = 'updatable'; -+ if (btnAction) { -+ btnAction.value = _('Click to update'); -+ btnAction.disabled = false; -+ } -+ if (btnForce) { -+ btnForce.style.display = 'none'; -+ } -+ -+ // Text only; link only if it points at the project's own releases. -+ let node = E('em', { 'style': 'color: red;' }, remoteVersion); -+ if (typeof data.html_url === 'string' && GITHUB_RELEASE_URL_REGEX.test(data.html_url)) { -+ node = E('a', { 'href': data.html_url, 'target': '_blank', 'rel': 'noopener noreferrer' }, node); -+ } -+ setDetail(node); -+ } else { -+ currentState = 'latest'; -+ if (btnAction) { -+ btnAction.value = _('It is the latest version'); -+ btnAction.disabled = true; -+ } -+ if (btnForce) { -+ btnForce.style.display = 'inline-block'; -+ btnForce.disabled = false; -+ } -+ setDetail(''); -+ } -+ }) -+ .catch(function() { -+ currentState = 'check_failed'; ++ if (!coreExists || !installed || !latest || compareVersions(latest, installed) > 0) { ++ showUpdatable(release); ++ setVersionHint(coreExists ? release : null); ++ } else { ++ currentState = 'latest'; + if (btnAction) { -+ btnAction.value = _('Retry'); -+ btnAction.disabled = false; ++ btnAction.value = _('It is the latest version'); ++ btnAction.disabled = true; + } + if (btnForce) { -+ btnForce.style.display = 'none'; ++ btnForce.style.display = 'inline-block'; ++ btnForce.disabled = false; + } -+ setDetail(E('em', { 'style': 'color: red;' }, _('Check failed'))); -+ }); ++ setDetail(''); ++ setVersionHint(null); ++ } ++ }).catch(function() { ++ currentState = 'check_failed'; ++ if (btnAction) { ++ btnAction.value = _('Retry'); ++ btnAction.disabled = false; ++ } ++ if (btnForce) { ++ btnForce.style.display = 'none'; ++ } ++ setDetail(E('em', { 'style': 'color: red;' }, _('Check failed'))); ++ }); + } + + function triggerUpdate(isForce) { -+ const btnAction = document.getElementById('btn-agh-action'); -+ const btnForce = document.getElementById('btn-agh-force'); ++ const btnAction = byId('btn-agh-action'); ++ const btnForce = byId('btn-agh-force'); + + currentState = 'updating'; + if (btnAction) { @@ -860,7 +1291,7 @@ index 6dc2d45..b0f0e06 100644 + const isCore = results[0]; + const isError = results[1]; + if (isCore || isError) { -+ const btnAction = document.getElementById('btn-agh-action'); ++ const btnAction = byId('btn-agh-action'); + currentState = 'updating'; + if (btnAction) { + btnAction.value = _('Updating...') + ' 0%'; @@ -870,6 +1301,34 @@ index 6dc2d45..b0f0e06 100644 + } + }); + ++ // Background check for a newer release: it runs after the page is shown, never ++ // delays it, and stays silent if GitHub cannot be reached. ++ detectNewRelease(detectedChannel); ++ ++ afterRerender = function() { ++ decorate(); ++ if (currentState !== 'updating') { ++ currentState = 'init'; ++ detectedChannel = currentChannel(); ++ detectNewRelease(detectedChannel); ++ } ++ }; ++ ++ // Changing the Core Branch dropdown invalidates what the Action button shows. ++ rendered.addEventListener('change', function() { ++ if (['init', 'updatable', 'latest', 'check_failed'].indexOf(currentState) === -1) { ++ return; ++ } ++ const channel = currentChannel(); ++ if (channel === detectedChannel) { ++ return; ++ } ++ detectedChannel = channel; ++ resetToCheck(); ++ setVersionHint(null); ++ detectNewRelease(channel); ++ }); + return rendered; - }, + } diff --git a/luci-app-adguardhome/root/etc/uci-defaults/adguardhome b/luci-app-adguardhome/root/etc/uci-defaults/adguardhome index d19d531ee..d3f3e55a8 100755 --- a/luci-app-adguardhome/root/etc/uci-defaults/adguardhome +++ b/luci-app-adguardhome/root/etc/uci-defaults/adguardhome @@ -66,8 +66,8 @@ elif [ -f "$OLD_CONFIG_FILE" ] && [ "$OLD_CONFIG_FILE" != "$NEW_CONFIG_FILE" ]; cert_path=$(grep certificate_path: "$CUR_CONFIG_FILE" \ | awk -F':' '{gsub(/"/, "", $2); gsub(/^ +| +$/, "", $2); print $2}') if [ -n "$cert_path" ]; then - echo "Found custom 'certificate_path' pointing to '$cert_path'." \ - + "Ensure configured service user '$USER' can access it." + echo "Found custom 'certificate_path' pointing to '$cert_path'." + echo "Ensure configured service user '$USER' can access it." stop_service @@ -79,8 +79,8 @@ elif [ -f "$OLD_CONFIG_FILE" ] && [ "$OLD_CONFIG_FILE" != "$NEW_CONFIG_FILE" ]; private_key_path=$(grep private_key_path: "$CUR_CONFIG_FILE" \ | awk -F':' '{gsub(/"/, "", $2); gsub(/^ +| +$/, "", $2); print $2}') if [ -n "$private_key_path" ]; then - echo "Found custom 'private_key_path' pointing to '$private_key_path'." \ - + "Ensure configured service user '$USER' can access it." + echo "Found custom 'private_key_path' pointing to '$private_key_path'." + echo "Ensure configured service user '$USER' can access it." stop_service @@ -107,3 +107,28 @@ elif [ -z "$OLD_CONFIG_NEW_OPT_FILE" ] && [ "$OLD_CONFIG_FILE" != "$NEW_CONFIG_F else echo "AdGuard Home config is in its default path '$NEW_CONFIG_FILE'" fi + +# Migrate the core-update settings: the old 'update_url' option stored a full +# download URL; it is replaced by 'update_source' (official|github), which +# update_core.sh maps to a fixed URL. The old URL is only classified by its +# host and is never used for downloading. Independent of the migration above. +if OLD_UPDATE_URL=$(uci -q get adguardhome.config.update_url); then + if [ -z "$(uci -q get adguardhome.config.update_source)" ]; then + case "$OLD_UPDATE_URL" in + https://github.com/*) + NEW_UPDATE_SOURCE=github + ;; + https://static.adtidy.org/*) + NEW_UPDATE_SOURCE=official + ;; + *) + NEW_UPDATE_SOURCE=official + echo "Custom 'update_url' is no longer supported; using the official mirror." + ;; + esac + uci set adguardhome.config.update_source="$NEW_UPDATE_SOURCE" + echo "Core update source set to '$NEW_UPDATE_SOURCE'" + fi + uci -q delete adguardhome.config.update_url + uci commit adguardhome +fi diff --git a/luci-app-adguardhome/root/usr/share/rpcd/acl.d/luci-app-adguardhome.json b/luci-app-adguardhome/root/usr/share/rpcd/acl.d/luci-app-adguardhome.json index 7d7cc4997..33862ecfb 100644 --- a/luci-app-adguardhome/root/usr/share/rpcd/acl.d/luci-app-adguardhome.json +++ b/luci-app-adguardhome/root/usr/share/rpcd/acl.d/luci-app-adguardhome.json @@ -5,6 +5,7 @@ "file": { "/tmp/AdGuardHome_update.log": [ "read" ], "/var/run/update_core": [ "stat" ], + "/var/run/update_core_done": [ "stat" ], "/var/run/update_core_error": [ "stat" ], "/var/run/AdGredir": [ "read" ], "/var/run/AdGserverdis": [ "read" ], @@ -17,7 +18,8 @@ }, "write": { "file": { - "/usr/share/AdGuardHome/update_core.sh": [ "exec" ] + "/usr/share/AdGuardHome/update_core.sh": [ "exec" ], + "/var/run/update_core_done": [ "write" ] }, "uci": [ "adguardhome" ] } diff --git a/luci-app-open-box/Makefile b/luci-app-open-box/Makefile index b586bc37d..146c80e71 100644 --- a/luci-app-open-box/Makefile +++ b/luci-app-open-box/Makefile @@ -1,8 +1,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-open-box -PKG_VERSION:=0.1.394 -PKG_RELEASE:=61 +PKG_VERSION:=0.1.397 +PKG_RELEASE:=62 PKG_MAINTAINER:=liandu2024 PKG_LICENSE:=GPL-3.0-or-later diff --git a/luci-app-trafficctl/Makefile b/luci-app-trafficctl/Makefile index 48f37fd35..b14bd30b3 100644 --- a/luci-app-trafficctl/Makefile +++ b/luci-app-trafficctl/Makefile @@ -1,8 +1,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-trafficctl -PKG_VERSION:=1.26.1 -PKG_RELEASE:=20 +PKG_VERSION:=1.26.3 +PKG_RELEASE:=21 PKG_LICENSE:=Apache-2.0 PKG_MAINTAINER:=Denis Iusupov diff --git a/luci-app-trafficctl/root/usr/local/bin/trafficctl-device.sh b/luci-app-trafficctl/root/usr/local/bin/trafficctl-device.sh index 1b4af29ee..46bc41f3a 100755 --- a/luci-app-trafficctl/root/usr/local/bin/trafficctl-device.sh +++ b/luci-app-trafficctl/root/usr/local/bin/trafficctl-device.sh @@ -164,11 +164,12 @@ fi # Get rate limit RATE_LIM=0 if [ "$TCTL_FW" = "nft" ]; then - RATE_LIM=$(nft list table netdev tm_ratelimit 2>/dev/null | grep "daddr $IP" | \ - grep -oE '[0-9]+ kbytes' | awk '{print $1 * 8}') + RATE_LIM=$(nft list table netdev tm_ratelimit 2>/dev/null | tctl_rl_rate_kbit "$IP") else - RATE_LIM=$(iptables -t mangle -L FORWARD -nv 2>/dev/null | grep "rl_ratelimit" | grep "$IP" | \ - grep -oE '[0-9]+kbit' | head -1 | sed 's/kbit//') + # The address as a whole field, not a substring (see tctl_rl_rate_kbit). + RATE_LIM=$(iptables -t mangle -L FORWARD -nv 2>/dev/null | grep "rl_ratelimit" \ + | awk -v ip="$IP" '{ for (i = 1; i <= NF; i++) if ($i == ip || $i == ip "/32") { print; next } }' \ + | grep -oE '[0-9]+kbit' | head -1 | sed 's/kbit//') fi [ -z "$RATE_LIM" ] && RATE_LIM=0 diff --git a/luci-app-trafficctl/root/usr/local/bin/trafficctl-fw.sh b/luci-app-trafficctl/root/usr/local/bin/trafficctl-fw.sh index 7288d015f..d2d521f83 100755 --- a/luci-app-trafficctl/root/usr/local/bin/trafficctl-fw.sh +++ b/luci-app-trafficctl/root/usr/local/bin/trafficctl-fw.sh @@ -124,21 +124,21 @@ tctl_ratelimit_add() { # directions could differ. Not zero: a zero ceiling policed every packet and # would be a silent total block reported as a successful limit. [ -z "$rate_kbit_up" ] && rate_kbit_up="$rate_kbit" - local rate_kbyte=$((rate_kbit / 8)) - [ "$rate_kbyte" -lt 1 ] && rate_kbyte=1 - local rate_kbyte_up=$((rate_kbit_up / 8)) - [ "$rate_kbyte_up" -lt 1 ] && rate_kbyte_up=1 + # bytes/second, not kbytes: see tctl_rate_bytes. + local rate_b rate_b_up + rate_b=$(tctl_rate_bytes "$rate_kbit") + rate_b_up=$(tctl_rate_bytes "$rate_kbit_up") local slug dl_expr ul_expr ul6_expr dl6_expr feed6_expr v6set mac slug=$(tctl_target_slug "$ip") # The two directions were already separate rules on separate hooks — they # were symmetric only because both took the same number. if [ "$mode" = "each" ]; then - dl_expr="ip daddr $ip meter tctl_d_$slug { ip daddr limit rate over ${rate_kbyte} kbytes/second }" - ul_expr="ip saddr $ip meter tctl_u_$slug { ip saddr limit rate over ${rate_kbyte_up} kbytes/second }" + dl_expr="ip daddr $ip meter tctl_d_$slug { ip daddr limit rate over ${rate_b} bytes/second }" + ul_expr="ip saddr $ip meter tctl_u_$slug { ip saddr limit rate over ${rate_b_up} bytes/second }" else - dl_expr="ip daddr $ip limit rate over ${rate_kbyte} kbytes/second" - ul_expr="ip saddr $ip limit rate over ${rate_kbyte_up} kbytes/second" + dl_expr="ip daddr $ip limit rate over ${rate_b} bytes/second" + ul_expr="ip saddr $ip limit rate over ${rate_b_up} bytes/second" fi # Upload over IPv6, keyed on the MAC: at LAN ingress the frame is still @@ -180,9 +180,9 @@ tctl_ratelimit_add() { ul6_expr=""; dl6_expr=""; feed6_expr="" v6set="tctl_v6_$slug" if mac=$(tctl_target_mac "$ip"); then - ul6_expr="meta protocol ip6 ether saddr $mac limit rate over ${rate_kbyte_up} kbytes/second" + ul6_expr="meta protocol ip6 ether saddr $mac limit rate over ${rate_b_up} bytes/second" feed6_expr="meta protocol ip6 ether saddr $mac ip6 saddr != fe80::/10 update @$v6set { ip6 saddr }" - dl6_expr="meta protocol ip6 ip6 daddr @$v6set limit rate over ${rate_kbyte} kbytes/second" + dl6_expr="meta protocol ip6 ip6 daddr @$v6set limit rate over ${rate_b} bytes/second" fi if [ "$TCTL_FW" = "nft" ]; then @@ -323,6 +323,63 @@ tctl_ratelimit_remove() { fi } +# Download ceiling in kbit/s of the limit on one address, read from an +# "nft list table netdev tm_ratelimit" dump on stdin; nothing when it has none. +# +# The address is compared as a whole FIELD. A grep for "daddr " also +# matched every address it is a prefix of, so 192.168.1.5 reported +# 192.168.1.50's limit, and the dots matched any character besides. Only the +# first rule counts: the download rule is installed on every LAN device's +# egress chain, so a router with several bridges holds several copies, and +# printing all of them handed printf a multi-line "number". +# +# nft prints the rate in the largest unit that keeps it whole, and its units +# step by 1024, not 1000: 1024 bytes/second lists as "1 kbytes/second" and +# 1024 kbytes/second as "1 mbytes/second" (verified with nftables 1.0.9). +# The readers that only knew "kbytes" turned the latter into 0 — or, in the +# stats, into 8 kbit/s. Every unit goes back to bytes by nft's factor, and +# bytes to kbit by the package's own, SI (see tctl_rate_bytes). +tctl_rl_rate_kbit() { + awk -v ip="$1" "$TCTL_NFT_RATE_AWK"' + /limit rate over/ { + hit = 0 + for (i = 1; i < NF; i++) + if ($i == "daddr" && ($(i+1) == ip || $(i+1) == ip "/32")) { hit = 1; break } + if (!hit) next + for (j = i; j < NF - 1; j++) + if ($j == "over") { printf "%.0f\n", tctl_rate_kbit($(j+1), $(j+2)); exit } + }' +} + +# A kbit/s ceiling as the bytes/second an nft "limit rate" is written in. +# +# kbit/s here is SI, 1000 bits, as everywhere else in the package: the UI, +# tc ("kbit" is 1000 bits to tc as well) and the speed graphs. nft's +# "kbytes" is 1024 bytes, so writing rate_kbit / 8 kbytes — what the +# limiter did — policed 2.4% above the ceiling it was given: 5000 kbit/s +# became 5120. Bytes are exact (kbit * 1000 / 8 = kbit * 125). +tctl_rate_bytes() { + local b=$(( $1 * 125 )) + [ "$b" -lt 1 ] && b=1 + echo "$b" +} + +# awk function shared by every reader of an nft rate: tctl_rate_kbit(n, unit) +# turns "n /second" as nft lists it back into SI kbit/s. Prepended to +# an awk program; a rule written before rates were stored in bytes reads as +# what it actually polices (625 kbytes/second is 5120 kbit/s). +TCTL_NFT_RATE_AWK=' +function tctl_rate_kbit(n, u, b) { + if (u ~ /^bytes/) b = n + else if (u ~ /^kbytes/) b = n * 1024 + else if (u ~ /^mbytes/) b = n * 1024 * 1024 + else if (u ~ /^gbytes/) b = n * 1024 * 1024 * 1024 + else return 0 + # Rounded here, so a reader printing %d agrees with one printing %.0f. + return int(b * 8 / 1000 + 0.5) +} +' + tctl_ratelimit_list() { if [ "$TCTL_FW" = "nft" ]; then nft list table netdev tm_ratelimit 2>/dev/null diff --git a/luci-app-trafficctl/root/usr/local/bin/trafficctl-portfw.sh b/luci-app-trafficctl/root/usr/local/bin/trafficctl-portfw.sh index 30bbb7fef..e3172a5ea 100755 --- a/luci-app-trafficctl/root/usr/local/bin/trafficctl-portfw.sh +++ b/luci-app-trafficctl/root/usr/local/bin/trafficctl-portfw.sh @@ -137,20 +137,19 @@ pfw_add_pause() { pfw_add_limit() { local scope="$1" proto="$2" ip="$3" port="$4" kbit="$5" - local cmt chain kbyte - kbyte=$((kbit / 8)) - [ "$kbyte" -lt 1 ] && kbyte=1 + local cmt chain rate_b + rate_b=$(tctl_rate_bytes "$kbit") cmt=$(pfw_comment "limit" "$proto" "${ip:-local}" "$port") if [ "$TCTL_FW" = "nft" ]; then ensure_pfw_table chain=$(pfw_chain_for_scope "$scope") if [ "$scope" = "input" ]; then nft add rule inet tctl_pfw "$chain" \ - "$proto" dport "$port" limit rate over "$kbyte" kbytes/second \ + "$proto" dport "$port" limit rate over "$rate_b" bytes/second \ counter drop comment "\"$cmt\"" else nft add rule inet tctl_pfw "$chain" \ - ip daddr "$ip" "$proto" dport "$port" limit rate over "$kbyte" kbytes/second \ + ip daddr "$ip" "$proto" dport "$port" limit rate over "$rate_b" bytes/second \ counter drop comment "\"$cmt\"" fi else @@ -371,7 +370,7 @@ do_list() { printf '%s' "$CTL_DUMP" | grep -q "$(pfw_comment pause "$p" "$cmt_ip" "$port")" && paused=true if [ "$limit_kbit" = "0" ]; then st=$(printf '%s' "$CTL_DUMP" | grep "$(pfw_comment limit "$p" "$cmt_ip" "$port")" | \ - grep -oE '[0-9]+ kbytes' | awk '{print $1 * 8; exit}') + awk "$TCTL_NFT_RATE_AWK"'{ for (i = 1; i < NF - 1; i++) if ($i == "over") { printf "%.0f\n", tctl_rate_kbit($(i+1), $(i+2)); exit } }') [ -z "$st" ] && st=$(printf '%s' "$CTL_DUMP" | grep "$(pfw_comment limit "$p" "$cmt_ip" "$port")" | \ grep -oE 'hashlimit-above [0-9]+' | awk '{print $2; exit}') [ -n "$st" ] && limit_kbit="$st" diff --git a/luci-app-trafficctl/root/usr/local/bin/trafficctl-ratelimit-stats.sh b/luci-app-trafficctl/root/usr/local/bin/trafficctl-ratelimit-stats.sh index e1d99a79b..bdab3d121 100755 --- a/luci-app-trafficctl/root/usr/local/bin/trafficctl-ratelimit-stats.sh +++ b/luci-app-trafficctl/root/usr/local/bin/trafficctl-ratelimit-stats.sh @@ -14,7 +14,7 @@ . /usr/local/bin/trafficctl-fw.sh if [ "$TCTL_FW" = "nft" ]; then - nft list table netdev tm_ratelimit 2>/dev/null | awk ' + nft list table netdev tm_ratelimit 2>/dev/null | awk "$TCTL_NFT_RATE_AWK"' /ip daddr/ && /limit rate/ && /counter/ { ip = "" rate = 0 @@ -31,8 +31,9 @@ if [ "$TCTL_FW" = "nft" ]; then if ($i == "rate" && $(i+1) == "over") { val = $(i+2) gsub(/[^0-9]/, "", val) - # stored as kbytes/second, convert back to kbit - rate = val * 8 + # nft lists the largest whole unit, stepping by 1024 + # (see tctl_rl_rate_kbit); back to SI kbit/s. + rate = tctl_rate_kbit(val, $(i+3)) } if ($i == "counter") { # format: counter packets N bytes N diff --git a/luci-app-trafficctl/root/usr/local/bin/trafficctl-summary.sh b/luci-app-trafficctl/root/usr/local/bin/trafficctl-summary.sh index 5a5c2bc3e..efc51757d 100755 --- a/luci-app-trafficctl/root/usr/local/bin/trafficctl-summary.sh +++ b/luci-app-trafficctl/root/usr/local/bin/trafficctl-summary.sh @@ -351,9 +351,12 @@ lookup_block_bytes() { lookup_rate_limit() { local ip="$1" r if [ "$TCTL_FW" = "nft" ]; then - r=$(echo "$RL_DUMP" | grep "daddr $ip" | grep -oE '[0-9]+ kbytes' | awk '{print $1 * 8}') + r=$(echo "$RL_DUMP" | tctl_rl_rate_kbit "$ip") else - r=$(echo "$RL_DUMP" | grep "rl_ratelimit" | grep "$ip" | grep -oE '[0-9]+kbit' | head -1 | sed 's/kbit//') + # The address as a whole field, not a substring (see tctl_rl_rate_kbit). + r=$(echo "$RL_DUMP" | grep "rl_ratelimit" \ + | awk -v ip="$ip" '{ for (i = 1; i <= NF; i++) if ($i == ip || $i == ip "/32") { print; next } }' \ + | grep -oE '[0-9]+kbit' | head -1 | sed 's/kbit//') fi echo "${r:-0}" }