diff --git a/luci-app-daede/Makefile b/luci-app-daede/Makefile index 27969a5c0..8f917070a 100644 --- a/luci-app-daede/Makefile +++ b/luci-app-daede/Makefile @@ -5,8 +5,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-daede -PKG_VERSION:=1.14.7 -PKG_RELEASE:=42 +PKG_VERSION:=1.15 +PKG_RELEASE:=43 PKG_MAINTAINER:=kenzok8 PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME) @@ -78,6 +78,7 @@ define Package/$(PKG_NAME)/install $(INSTALL_BIN) ./root/usr/share/luci-app-daede/proxy-check.sh $(1)/usr/share/luci-app-daede/proxy-check.sh $(INSTALL_BIN) ./root/usr/share/luci-app-daede/fetch-clash-yaml.sh $(1)/usr/share/luci-app-daede/fetch-clash-yaml.sh $(INSTALL_BIN) ./root/usr/share/luci-app-daede/config-backup.sh $(1)/usr/share/luci-app-daede/config-backup.sh + $(INSTALL_BIN) ./root/usr/share/luci-app-daede/snapshot-file.sh $(1)/usr/share/luci-app-daede/snapshot-file.sh $(INSTALL_BIN) ./root/usr/share/luci-app-daede/config-defaults.sh $(1)/usr/share/luci-app-daede/config-defaults.sh $(INSTALL_DIR) $(1)/usr/share/luci-app-daede/defaults $(INSTALL_DATA) $(CURDIR)/../dae/files/dae.config $(1)/usr/share/luci-app-daede/defaults/dae diff --git a/luci-app-daede/htdocs/luci-static/resources/view/daede/converter.js b/luci-app-daede/htdocs/luci-static/resources/view/daede/converter.js index ed59b5c93..455460cab 100644 --- a/luci-app-daede/htdocs/luci-static/resources/view/daede/converter.js +++ b/luci-app-daede/htdocs/luci-static/resources/view/daede/converter.js @@ -13,6 +13,7 @@ const FETCHER = '/usr/share/luci-app-daede/fetch-clash-yaml.sh'; const GENERATOR = '/usr/share/luci-app-daede/gen-dae-config.sh'; +const SNAPSHOT_HELPER = '/usr/share/luci-app-daede/snapshot-file.sh'; const SUB_STAGE = '/tmp/daede-sub.txt'; const FETCH_CHUNK_BYTES = 16384; @@ -651,11 +652,19 @@ return view.extend({ const name = airportName.value.trim(); const groupName = airportSync.backendGroupName(name, 'daed', airportId); const endpoint = daedEndpoint(); - // daed can't read file:// — it HTTP-fetches a subscription link. Stage - // the converted links (base64) and let daed pull them from the - // loopback-only CGI, so the import lands as ONE subscription. - const subToken = airportSync.backendId(airportId) + Date.now().toString(36); - const stageFile = '/tmp/daede-daedsub-' + subToken; + // daed can't read file:// — it HTTP-fetches a subscription link. Persist + // the converted snapshot and let daed pull it from the loopback-only CGI, + // so later daed refreshes and configuration backups keep working. + // Keep the CGI token opaque, bounded, and unique across imports started in + // the same millisecond. The final sanitization also protects the filename + // and the CGI's allow-list if backendId ever changes. + const tokenTime = Date.now().toString(36); + const tokenRandom = Math.random().toString(36).slice(2, 12); + const tokenPrefix = airportSync.backendId(airportId).replace(/[^A-Za-z0-9]/g, '') + .slice(0, Math.max(0, 64 - tokenTime.length - tokenRandom.length)); + const subToken = (tokenPrefix + tokenTime + tokenRandom).slice(0, 64); + const snapshotFile = '/etc/daed/daede-sub-' + subToken; + const stageFile = '/etc/daed/.daede-sub-stage-' + subToken; const subUrl = 'http://127.0.0.1/cgi-bin/daede-sub?t=' + subToken; const b64 = btoa(items.map(function(item) { return item.link; }).join('\n') + '\n'); @@ -664,15 +673,156 @@ return view.extend({ let createdGroupId = ''; const createdNodeIds = []; let groupReady = false; + let snapshotCommitted = false; + let snapshotFinalToken = ''; + let reuseExistingSnapshot = false; const oldSubId = existingAirport ? existingAirport.subscription_id : ''; const oldNodeIds = existingAirport ? existingAirport.node_ids : []; - const dropStage = function() { return fs.exec('/bin/rm', [ '-f', stageFile ]).catch(function() {}); }; + const dropSnapshot = function(path) { + if (!path) + return Promise.resolve(); + return fs.remove(path).catch(function(error) { + const message = String(error && (error.message || error) || ''); + if (/not found|no such file|enoent/i.test(message)) + return; + throw error; + }); + }; + const appendWarning = function(error, warning) { + const result = error instanceof Error ? error : new Error(String(error && (error.message || error) || error)); + result.message = result.message + '; ' + warning; + return result; + }; + const snapshotAction = function(args) { + return fs.exec(SNAPSHOT_HELPER, args).then(function(res) { + if (!res || res.code !== 0) + throw new Error((res && (res.stderr || res.stdout)) || _('Snapshot operation failed')); + return res; + }); + }; + const cleanupStage = function() { + return snapshotAction([ 'discard', subToken ]); + }; + const querySubscriptions = function() { + if (!token) + return Promise.reject(new Error(_('daed subscription cleanup could not be verified'))); + return graphQL(endpoint, 'query SubscriptionLinks{subscriptions{id link}}', {}, token).then(function(value) { + if (!value || !Array.isArray(value.subscriptions)) + throw new Error(_('daed returned an invalid subscription list')); + return value.subscriptions; + }); + }; + const dropSnapshotIfUnreferenced = function() { + if (snapshotFinalToken !== subToken) + return Promise.resolve(); + return querySubscriptions().then(function(subscriptions) { + if (subscriptions.some(function(sub) { return String(sub.link || '') === subUrl; })) + throw new Error(_('new daed subscription snapshot is still referenced; it was kept')); + return dropSnapshot(snapshotFile); + }); + }; + const finishWithSnapshotCleanup = function(result) { + return dropSnapshotIfUnreferenced().then(function() { + return result; + }).catch(function(error) { + const warning = String(error && (error.message || error) || error); + result.warning = result.warning + ? result.warning + '; ' + warning + : warning; + return result; + }); + }; + const updateExistingSubscription = function() { + const old = (((before || {}).subscriptions) || []).find(function(sub) { return sub.id === oldSubId; }); + if (!old) + return Promise.reject(new Error(_('Managed subscription no longer exists in daed'))); + + const oldLink = String(old.link || ''); + const localToken = oldLink.match(/^http:\/\/127\.0\.0\.1\/cgi-bin\/daede-sub\?t=([A-Za-z0-9]{1,64})$/); + const refresh = function() { + return graphQL(endpoint, 'mutation UpdateSub($id:ID!){updateSubscription(id:$id){id}}', { id: oldSubId }, token); + }; + const currentLink = function() { + return querySubscriptions().then(function(subscriptions) { + const current = subscriptions.find(function(sub) { return sub.id === oldSubId; }); + return current ? String(current.link || '') : ''; + }); + }; + const restoreOldLink = function(originalError) { + return graphQL(endpoint, 'mutation RestoreLink($id:ID!,$link:String!){updateSubscriptionLink(id:$id,link:$link)}', { + id: oldSubId, + link: oldLink + }, token).then(refresh).then(currentLink).then(function(link) { + if (link !== oldLink) { + snapshotCommitted = true; + throw appendWarning(originalError, _('rollback was not confirmed; both subscription snapshots were kept')); + } + throw originalError; + }, function(rollbackError) { + snapshotCommitted = true; + throw appendWarning(originalError, _('rollback could not be confirmed; both subscription snapshots were kept: %s').format(String(rollbackError && (rollbackError.message || rollbackError) || rollbackError))); + }); + }; + const finish = function() { + const tag = old.tag !== groupName + ? graphQL(endpoint, 'mutation TagSub($id:ID!,$tag:String!){tagSubscription(id:$id,tag:$tag)}', { + id: oldSubId, + tag: groupName + }, token).catch(function(error) { + return String(error && (error.message || error) || error); + }) + : Promise.resolve(''); + return tag.then(function(tagWarning) { + writeAirportRecord(existingAirport, { + id: airportId, + backend: 'daed', + name: name, + sourceHash: state.sourceHash, + groupId: existingAirport.group_id, + subscriptionId: oldSubId, + nodeIds: [] + }); + return applyUciChanges().then(function() { + items.forEach(function(item) { item.duplicate = true; item.selected = false; }); + return { + added: items.length, + duplicates: 0, + failed: 0, + warning: tagWarning + }; + }); + }); + }; + + if (localToken && reuseExistingSnapshot) { + return graphQL(endpoint, 'mutation UpdateSub($id:ID!){updateSubscription(id:$id){id}}', { id: oldSubId }, token) + .then(currentLink).then(function(link) { + if (link !== oldLink) + throw new Error(_('daed did not confirm the existing subscription link after refresh')); + }).catch(function(error) { + throw appendWarning(error, _('daed subscription refresh failed; the new snapshot remains at the existing link and existing nodes were kept')); + }).then(finish); + } + + return graphQL(endpoint, 'mutation SetLink($id:ID!,$link:String!){updateSubscriptionLink(id:$id,link:$link)}', { + id: oldSubId, + link: subUrl + }, token).then(refresh).then(function() { + return currentLink().then(function(link) { + if (link !== subUrl) + throw new Error(_('daed did not confirm the new subscription link')); + }); + }).catch(restoreOldLink).then(function() { + snapshotCommitted = true; + return finish(); + }); + }; const loadState = function(forceLogin) { return requestDaedToken(endpoint, forceLogin).then(function(auth) { token = auth.token; usedCachedToken = auth.cached; - return graphQL(endpoint, 'query State{nodes(first:10000){edges{id link tag}} groups{id name nodes{id}}}', {}, token); + return graphQL(endpoint, 'query State{nodes(first:10000){edges{id link tag}} groups{id name nodes{id}} subscriptions{id link tag}}', {}, token); }); }; const importDirectNodes = function() { @@ -797,7 +947,7 @@ return view.extend({ }); }; - return fs.write(stageFile, b64).then(function() { + return fs.write(stageFile, b64, 384).then(function() { return loadState(false).catch(function(error) { if (!usedCachedToken || !daedSession.isAccessDenied(error)) throw error; @@ -806,42 +956,63 @@ return view.extend({ }); }).then(function(dataValue) { before = dataValue; - // drop this airport's previous subscription first: the new one - // reuses the same tag (group name) and daed enforces unique tags. - if (!oldSubId) - return null; - return graphQL(endpoint, 'mutation RmSub($ids:[ID!]!){removeSubscriptions(ids:$ids)}', { ids: [ oldSubId ] }, token).catch(function() {}); - }).then(function() { - // import the whole converted batch as one subscription - return graphQL(endpoint, - 'mutation Import($a:ImportArgument!){importSubscription(rollbackError:false,arg:$a){sub{id} nodeImportResult{error}}}', - { a: { link: subUrl, tag: groupName } }, token).then(function(result) { - const sub = result.importSubscription && result.importSubscription.sub; - const rows = (result.importSubscription && result.importSubscription.nodeImportResult) || []; - const usable = rows.length - ? rows.some(function(r) { return !r.error || r.error === 'node already exists'; }) - : !!(sub && sub.id); - if (!sub || !sub.id || !usable) { - const details = rows.map(function(r) { return r.error; }).filter(Boolean).join('; '); - if (sub && sub.id) - newSubId = sub.id; - throw new Error(details || _('No usable nodes were imported')); - } - return result; - }).catch(function(error) { - if (daedSession.isAccessDenied(error)) - throw error; - const cleanupSub = newSubId - ? graphQL(endpoint, 'mutation Rm($ids:[ID!]!){removeSubscriptions(ids:$ids)}', { ids: [ newSubId ] }, token).catch(function() {}) - : Promise.resolve(); - newSubId = ''; - return cleanupSub.then(importDirectNodes).then(function(result) { - return { directResult: result }; - }); + const old = (before.subscriptions || []).find(function(sub) { return sub.id === oldSubId; }); + const localToken = old && String(old.link || '').match(/^http:\/\/127\.0\.0\.1\/cgi-bin\/daede-sub\?t=([A-Za-z0-9]{1,64})$/); + const oldLinkReferences = localToken + ? (before.subscriptions || []).filter(function(sub) { return String(sub.link || '') === String(old.link || ''); }) + : []; + reuseExistingSnapshot = !!(localToken && oldLinkReferences.length === 1 && String(oldLinkReferences[0].id) === String(oldSubId)); + const publish = reuseExistingSnapshot + ? snapshotAction([ 'replace', localToken[1], subToken ]).then(function() { + snapshotFinalToken = localToken[1]; + snapshotCommitted = true; + }) + : snapshotAction([ 'publish', subToken ]).then(function() { + snapshotFinalToken = subToken; }); + return publish.then(function() { + if (oldSubId) + return updateExistingSubscription().then(function(result) { return { existingResult: result }; }); + // import the whole converted batch as one subscription + return graphQL(endpoint, + 'mutation Import($a:ImportArgument!){importSubscription(rollbackError:false,arg:$a){sub{id} nodeImportResult{error}}}', + { a: { link: subUrl, tag: groupName } }, token).then(function(result) { + const sub = result.importSubscription && result.importSubscription.sub; + const rows = (result.importSubscription && result.importSubscription.nodeImportResult) || []; + const usable = rows.length + ? rows.some(function(r) { return !r.error || r.error === 'node already exists'; }) + : !!(sub && sub.id); + if (!sub || !sub.id || !usable) { + const details = rows.map(function(r) { return r.error; }).filter(Boolean).join('; '); + if (sub && sub.id) + newSubId = sub.id; + throw new Error(details || _('No usable nodes were imported')); + } + return result; + }).catch(function(error) { + if (daedSession.isAccessDenied(error)) + throw error; + const cleanupSub = newSubId + ? graphQL(endpoint, 'mutation Rm($ids:[ID!]!){removeSubscriptions(ids:$ids)}', { ids: [ newSubId ] }, token).catch(function(cleanupError) { + snapshotCommitted = true; + throw appendWarning(error, _('rollback could not be confirmed; the new subscription snapshot was kept: %s').format(String(cleanupError && (cleanupError.message || cleanupError) || cleanupError))); + }) + : Promise.resolve(); + newSubId = ''; + return cleanupSub.then(function() { + if (oldSubId) + throw error; + return importDirectNodes(); + }).then(function(result) { + return { directResult: result }; + }); + }); + }); }).then(function(result) { + if (result.existingResult) + return result.existingResult; if (result.directResult) - return result.directResult; + return finishWithSnapshotCleanup(result.directResult); const sub = result.importSubscription && result.importSubscription.sub; newSubId = sub.id; @@ -861,6 +1032,7 @@ return view.extend({ return ensureGroup.then(function(groupId) { return graphQL(endpoint, 'mutation AddSubs($id:ID!,$ids:[ID!]!){groupAddSubscriptions(id:$id,subscriptionIDs:$ids)}', { id: groupId, ids: [ newSubId ] }, token).then(function() { groupReady = true; + snapshotCommitted = true; const cleanup = []; // Migrate converter-managed airport groups to proxy, but never // disturb proxy's existing nodes or other subscriptions. @@ -880,7 +1052,12 @@ return view.extend({ }); return applyUciChanges().then(function() { items.forEach(function(item) { item.duplicate = true; item.selected = false; }); - return { added: items.length, duplicates: 0, failed: failed }; + return { + added: items.length, + duplicates: 0, + failed: failed, + warning: '' + }; }); }); }); @@ -892,15 +1069,47 @@ return view.extend({ throw error; const cleanup = []; if (newSubId) - cleanup.push(graphQL(endpoint, 'mutation Rm($ids:[ID!]!){removeSubscriptions(ids:$ids)}', { ids: [ newSubId ] }, token)); + cleanup.push(graphQL(endpoint, 'mutation Rm($ids:[ID!]!){removeSubscriptions(ids:$ids)}', { ids: [ newSubId ] }, token).catch(function(cleanupError) { + snapshotCommitted = true; + throw cleanupError; + })); if (createdNodeIds.length) cleanup.push(graphQL(endpoint, 'mutation Rm($ids:[ID!]!){removeNodes(ids:$ids)}', { ids: createdNodeIds }, token)); if (createdGroupId) cleanup.push(graphQL(endpoint, 'mutation RmG($id:ID!){removeGroup(id:$id)}', { id: createdGroupId }, token)); - return Promise.all(cleanup).catch(function() {}).then(function() { throw error; }); - }).finally(function() { - token = ''; - return dropStage(); + return Promise.all(cleanup).then(function() { + throw error; + }, function(cleanupError) { + snapshotCommitted = true; + throw appendWarning(error, _('rollback could not be confirmed; the new subscription snapshot was kept: %s').format(String(cleanupError && (cleanupError.message || cleanupError) || cleanupError))); + }); + }).then(function(result) { + return cleanupStage().then(function() { + token = ''; + return result; + }, function(cleanupError) { + token = ''; + result.warning = result.warning + ? result.warning + '; ' + String(cleanupError && (cleanupError.message || cleanupError) || cleanupError) + : String(cleanupError && (cleanupError.message || cleanupError) || cleanupError); + return result; + }); + }, function(error) { + let cleanupWarning = ''; + const removeSnapshot = snapshotCommitted + ? Promise.resolve() + : dropSnapshotIfUnreferenced().catch(function(cleanupError) { + cleanupWarning = _('rollback could not be confirmed; the new subscription snapshot was kept: %s').format(String(cleanupError && (cleanupError.message || cleanupError) || cleanupError)); + }); + return removeSnapshot.then(function() { + return cleanupStage().catch(function(stageError) { + const warning = _('snapshot stage cleanup failed: %s').format(String(stageError && (stageError.message || stageError) || stageError)); + cleanupWarning = cleanupWarning ? cleanupWarning + '; ' + warning : warning; + }); + }).then(function() { + token = ''; + throw cleanupWarning ? appendWarning(error, cleanupWarning) : error; + }); }); }; @@ -924,8 +1133,11 @@ return view.extend({ setImportStatus(_('Importing node group…')); const action = state.target === 'dae' ? importDae(items) : importDaed(items); action.then(function(result) { - setImportStatus(_('Node group imported: added %d, reused %d, failed %d') - .format(result.added, result.duplicates, result.failed), result.failed ? 'err' : 'ok'); + let message = _('Node group imported: added %d, reused %d, failed %d') + .format(result.added, result.duplicates, result.failed); + if (result.warning) + message += ' ' + _('Completed with warning: %s').format(result.warning); + setImportStatus(message, result.failed || result.warning ? 'err' : 'ok'); renderResults(); }).catch(function(e) { setImportStatus(_('Node group import failed: %s').format(e.message || e), 'err'); diff --git a/luci-app-daede/po/templates/daede.pot b/luci-app-daede/po/templates/daede.pot index 4dca08586..84705320d 100644 --- a/luci-app-daede/po/templates/daede.pot +++ b/luci-app-daede/po/templates/daede.pot @@ -812,6 +812,12 @@ msgstr "" msgid "Node group imported: added %d, reused %d, failed %d" msgstr "" +msgid "Completed with warning: %s" +msgstr "" + +msgid "Managed subscription no longer exists in daed" +msgstr "" + msgid "Node group import failed: %s" msgstr "" diff --git a/luci-app-daede/po/zh-cn/daede.po b/luci-app-daede/po/zh-cn/daede.po index 67cb02303..1a95c4173 100644 --- a/luci-app-daede/po/zh-cn/daede.po +++ b/luci-app-daede/po/zh-cn/daede.po @@ -1004,6 +1004,12 @@ msgstr "正在导入节点组…" msgid "Node group imported: added %d, reused %d, failed %d" msgstr "节点组导入完成:新增 %d,复用 %d,失败 %d" +msgid "Completed with warning: %s" +msgstr "已完成,但有警告:%s" + +msgid "Managed subscription no longer exists in daed" +msgstr "daed 中已不存在该托管订阅" + msgid "Node group import failed: %s" msgstr "节点组导入失败:%s" diff --git a/luci-app-daede/po/zh_Hans/daede.po b/luci-app-daede/po/zh_Hans/daede.po index 67cb02303..1a95c4173 100644 --- a/luci-app-daede/po/zh_Hans/daede.po +++ b/luci-app-daede/po/zh_Hans/daede.po @@ -1004,6 +1004,12 @@ msgstr "正在导入节点组…" msgid "Node group imported: added %d, reused %d, failed %d" msgstr "节点组导入完成:新增 %d,复用 %d,失败 %d" +msgid "Completed with warning: %s" +msgstr "已完成,但有警告:%s" + +msgid "Managed subscription no longer exists in daed" +msgstr "daed 中已不存在该托管订阅" + msgid "Node group import failed: %s" msgstr "节点组导入失败:%s" diff --git a/luci-app-daede/root/usr/share/luci-app-daede/config-backup.sh b/luci-app-daede/root/usr/share/luci-app-daede/config-backup.sh index c76838adf..1f93b56b1 100755 --- a/luci-app-daede/root/usr/share/luci-app-daede/config-backup.sh +++ b/luci-app-daede/root/usr/share/luci-app-daede/config-backup.sh @@ -31,6 +31,12 @@ paths() { case "${name%.sub}" in ''|*[!A-Za-z0-9_]*) fail 'unexpected subscription filename' ;; esac printf 'etc/dae/subscriptions/%s\n' "$name" done + for sub in "$1"/etc/daed/daede-sub-*; do + [ -e "$sub" ] || [ -L "$sub" ] || continue + name="${sub##*/daede-sub-}" + case "$name" in ''|*[!A-Za-z0-9]*) fail 'unexpected daed subscription filename' ;; esac + printf 'etc/daed/daede-sub-%s\n' "$name" + done } check_paths() { for dir in /etc/config /etc/dae /etc/daed /etc/dae/subscriptions; do @@ -42,6 +48,14 @@ check_paths() { [ ! -e "/$p" ] || [ -f "/$p" ] || fail "not a regular file: /$p" done < "$WORK/paths" } +cleanup_stages() { + for stage in /etc/daed/.daede-sub-stage-*; do + [ -e "$stage" ] || [ -L "$stage" ] || continue + [ ! -L "$stage" ] || fail "refusing symlink: $stage" + [ -f "$stage" ] || fail "not a regular file: $stage" + rm -f "$stage" || fail "failed to remove stale snapshot stage: $stage" + done +} snapshot() { mkdir -p "$WORK/before" while IFS= read -r p; do @@ -139,6 +153,9 @@ validate_archive() { etc/dae/subscriptions/*.sub) name="${p#etc/dae/subscriptions/}" case "${name%.sub}" in ''|*[!A-Za-z0-9_]*) fail 'invalid subscription path' ;; esac ;; + etc/daed/daede-sub-*) + name="${p#etc/daed/daede-sub-}" + case "$name" in ''|*[!A-Za-z0-9]*) fail 'invalid daed subscription path' ;; esac ;; *) fail 'unexpected archive entry' ;; esac done < "$WORK/entries" @@ -175,6 +192,7 @@ run_action() { fi fi stop_backends + [ "$ACTION" = reset ] && cleanup_stages snapshot if [ "$ACTION" = export ]; then paths "$WORK/before" > "$WORK/candidates" diff --git a/luci-app-daede/root/usr/share/luci-app-daede/snapshot-file.sh b/luci-app-daede/root/usr/share/luci-app-daede/snapshot-file.sh new file mode 100755 index 000000000..690977686 --- /dev/null +++ b/luci-app-daede/root/usr/share/luci-app-daede/snapshot-file.sh @@ -0,0 +1,81 @@ +#!/bin/sh +# Publish converter snapshots without exposing a partially written final file. + +set -eu + +DIR=/etc/daed +PREFIX=daede-sub +STAGE_PREFIX=.daede-sub-stage + +fail() { + echo "$*" >&2 + exit 1 +} + +valid_token() { + [ "$#" -eq 1 ] || return 1 + case "$1" in + ''|*[!A-Za-z0-9]*) return 1 ;; + esac + [ "${#1}" -le 64 ] +} + +stage_path() { printf '%s/%s-%s\n' "$DIR" "$STAGE_PREFIX" "$1"; } +final_path() { printf '%s/%s-%s\n' "$DIR" "$PREFIX" "$1"; } + +regular_file() { + [ -f "$1" ] && [ ! -L "$1" ] +} + +safe_dir() { + [ -d "$DIR" ] && [ ! -L "$DIR" ] || fail "invalid snapshot directory" +} + +publish() { + [ "$#" -eq 1 ] || fail 'publish expects one token' + valid_token "$1" || fail 'invalid snapshot token' + safe_dir + stage=$(stage_path "$1") + final=$(final_path "$1") + regular_file "$stage" || fail 'snapshot stage is not a regular file' + [ ! -e "$final" ] && [ ! -L "$final" ] || fail 'snapshot already exists' + chmod 600 "$stage" || fail 'cannot secure snapshot stage' + mv -f "$stage" "$final" || fail 'cannot publish snapshot' +} + +replace() { + [ "$#" -eq 2 ] || fail 'replace expects existing and stage tokens' + valid_token "$1" || fail 'invalid existing snapshot token' + valid_token "$2" || fail 'invalid stage snapshot token' + safe_dir + [ "$1" != "$2" ] || fail 'existing and stage tokens must differ' + stage=$(stage_path "$2") + final=$(final_path "$1") + regular_file "$stage" || fail 'snapshot stage is not a regular file' + if [ -e "$final" ] || [ -L "$final" ]; then + regular_file "$final" || fail 'existing snapshot is not a regular file' + fi + chmod 600 "$stage" || fail 'cannot secure snapshot stage' + mv -f "$stage" "$final" || fail 'cannot replace snapshot' +} + +discard() { + [ "$#" -eq 1 ] || fail 'discard expects one token' + valid_token "$1" || fail 'invalid snapshot token' + safe_dir + stage=$(stage_path "$1") + if [ -L "$stage" ]; then + fail 'refusing symlink snapshot stage' + fi + if [ -e "$stage" ]; then + [ -f "$stage" ] || fail 'snapshot stage is not a regular file' + rm -f "$stage" || fail 'cannot discard snapshot stage' + fi +} + +case "${1:-}" in + publish) shift; publish "$@" ;; + replace) shift; replace "$@" ;; + discard) shift; discard "$@" ;; + *) fail 'unknown snapshot action' ;; +esac diff --git a/luci-app-daede/root/usr/share/rpcd/acl.d/luci-app-daede.json b/luci-app-daede/root/usr/share/rpcd/acl.d/luci-app-daede.json index 7ad9aa86f..dd748864d 100644 --- a/luci-app-daede/root/usr/share/rpcd/acl.d/luci-app-daede.json +++ b/luci-app-daede/root/usr/share/rpcd/acl.d/luci-app-daede.json @@ -43,9 +43,12 @@ "/tmp/dae-validate.dae": [ "write" ], "/tmp/daede-import.b64": [ "write" ], "/tmp/daede-sub.txt": [ "write" ], - "/tmp/daede-daedsub-*": [ "write" ], - "/bin/rm -f /tmp/daede-daedsub-*": [ "exec" ], + "/etc/daed/.daede-sub-stage-*": [ "write" ], + "/etc/daed/daede-sub-*": [ "write" ], "/usr/share/luci-app-daede/config-backup.sh import": [ "exec" ], + "/usr/share/luci-app-daede/snapshot-file.sh publish *": [ "exec" ], + "/usr/share/luci-app-daede/snapshot-file.sh replace * *": [ "exec" ], + "/usr/share/luci-app-daede/snapshot-file.sh discard *": [ "exec" ], "/usr/share/luci-app-daede/gen-dae-config.sh write-sub *": [ "exec" ], "/usr/share/luci-app-daede/gen-dae-config.sh delete-sub *": [ "exec" ], "/etc/config/daede": [ "write" ], diff --git a/luci-app-daede/root/www/cgi-bin/daede-sub b/luci-app-daede/root/www/cgi-bin/daede-sub index 081acb312..f368d87b9 100644 --- a/luci-app-daede/root/www/cgi-bin/daede-sub +++ b/luci-app-daede/root/www/cgi-bin/daede-sub @@ -2,13 +2,13 @@ # daede-sub - serve a converter's staged node list to the local daed only. # # daed (dae-wing) can only build a subscription by HTTP-fetching a link, and it -# does not read file:// like dae core. So the converter stages the base64 share -# links in /tmp/daede-daedsub- and points daed's importSubscription at +# does not read file:// like dae core. So the converter stores the base64 share +# links in /etc/daed/daede-sub- and points daed's importSubscription at # http://127.0.0.1/cgi-bin/daede-sub?t=. This CGI is the read side. # # Locked to loopback (REMOTE_ADDR), so even though uhttpd listens on the LAN no -# remote client can pull the links. The frontend deletes the staged file after -# the import (success or failure), so the exposure is one local fetch. +# remote client can pull the links. The snapshot remains available for daed's +# own refresh action and is included in daede configuration backups. deny() { printf 'Status: 403 Forbidden\r\nContent-Type: text/plain\r\n\r\nforbidden\n'; exit 0; } @@ -18,7 +18,7 @@ deny() { printf 'Status: 403 Forbidden\r\nContent-Type: text/plain\r\n\r\nforbid t=$(printf '%s' "$QUERY_STRING" | sed -n 's/^.*\bt=\([A-Za-z0-9]\{1,64\}\).*$/\1/p') [ -n "$t" ] || deny -f="/tmp/daede-daedsub-$t" +f="/etc/daed/daede-sub-$t" [ -f "$f" ] || deny printf 'Content-Type: text/plain\r\n\r\n' diff --git a/luci-app-fwlive/Makefile b/luci-app-fwlive/Makefile index 2c6d00bfd..c8f1fd6af 100644 --- a/luci-app-fwlive/Makefile +++ b/luci-app-fwlive/Makefile @@ -12,7 +12,7 @@ LUCI_DEPENDS:=+luci-base +logd +jsonfilter LUCI_PKGARCH:=all PKG_VERSION:=0.1.38 -PKG_RELEASE:=8 +PKG_RELEASE:=9 # Reproducible build: honor SOURCE_DATE_EPOCH from the build environment (set by docker-sdk.sh / CI). ifdef SOURCE_DATE_EPOCH diff --git a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/buffer.js b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/buffer.js index e7826867f..d291d89c4 100644 --- a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/buffer.js +++ b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/buffer.js @@ -1,4 +1,6 @@ 'use strict'; +/* SPDX-License-Identifier: Apache-2.0 */ +/* Copyright 2025-2026 Lucas Albers */ 'require baseclass'; /** @@ -16,26 +18,24 @@ function ingestCap(paused, rowLimit, fetchLinesMax) { function mergeById(entries, normalized, cap) { if (!normalized || !normalized.length) { - if (!entries || !entries.length) - return []; + if (!entries || !entries.length) return []; return entries.slice(-cap); } const byId = {}; let i; if (entries) { - for (i = 0; i < entries.length; i++) - byId[entries[i].id] = entries[i]; + for (i = 0; i < entries.length; i++) byId[entries[i].id] = entries[i]; } - for (i = 0; i < normalized.length; i++) - byId[normalized[i].id] = normalized[i]; + for (i = 0; i < normalized.length; i++) byId[normalized[i].id] = normalized[i]; - const merged = Object.keys(byId).map(function(id) { return byId[id]; }); - merged.sort(function(a, b) { + const merged = Object.keys(byId).map(function (id) { + return byId[id]; + }); + merged.sort(function (a, b) { const ta = a.timestamp || 0; const tb = b.timestamp || 0; - if (ta !== tb) - return ta - tb; + if (ta !== tb) return ta - tb; return (a.log_id || 0) - (b.log_id || 0); }); return merged.slice(-cap); @@ -58,13 +58,10 @@ function applyFetchedEntries(entries, normalized, opts) { const merge = paused || resumeMerge; let next; - if (merge) - next = mergeById(entries, normalized, cap); - else - next = (normalized || []).slice(-cap); + if (merge) next = mergeById(entries, normalized, cap); + else next = (normalized || []).slice(-cap); - if (!paused && next.length > rowLimit) - next = next.slice(-rowLimit); + if (!paused && next.length > rowLimit) next = next.slice(-rowLimit); return next; } diff --git a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js index a2c09af02..9ad561102 100644 --- a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js +++ b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js @@ -1,4 +1,6 @@ 'use strict'; +/* SPDX-License-Identifier: Apache-2.0 */ +/* Copyright 2025-2026 Lucas Albers */ 'require baseclass'; /* LuCI require() needs Class.isSubclass — plain return {} fails */ 'require fwlive.log as log'; @@ -16,16 +18,13 @@ function chipValueNodes(field, val) { const p = log.parseFilterValue(val); - if (!p.value) - return [ '' ]; + if (!p.value) return ['']; - const valueNode = p.negate - ? E('span', { 'class': 'fwlive-chip-strike' }, [ p.value ]) - : p.value; + const valueNode = p.negate ? E('span', { 'class': 'fwlive-chip-strike' }, [p.value]) : p.value; if (!p.negate) { return [ - E('span', { 'class': 'fwlive-chip-polarity' }, [ _('is') ]), + E('span', { 'class': 'fwlive-chip-polarity' }, [_('is')]), ' ', log.formatFilterChipLabel(field, val) ]; @@ -34,26 +33,24 @@ function chipValueNodes(field, val) { if (field === 'q' || field === 'src' || field === 'dst') return [ field + ': ', - E('strong', { 'class': 'fwlive-chip-not' }, [ _('not') ]), + E('strong', { 'class': 'fwlive-chip-not' }, [_('not')]), ' contains ', valueNode ]; - return [ - field + ': ', - E('strong', { 'class': 'fwlive-chip-not' }, [ _('not') ]), - ' ', - valueNode - ]; + return [field + ': ', E('strong', { 'class': 'fwlive-chip-not' }, [_('not')]), ' ', valueNode]; } function chipLeadingSym(negated) { - if (!negated) - return null; - return E('span', { - 'class': 'fwlive-chip-sym fwlive-chip-sym-light', - 'aria-hidden': 'true' - }, [ '≠' ]); + if (!negated) return null; + return E( + 'span', + { + 'class': 'fwlive-chip-sym fwlive-chip-sym-light', + 'aria-hidden': 'true' + }, + ['≠'] + ); } function renderFilterChips(host, state, callbacks) { @@ -64,38 +61,62 @@ function renderFilterChips(host, state, callbacks) { for (let i = 0; i < chipFields.length; i++) { const spec = chipFields[i]; const val = filters[spec.key]; - if (!val) - continue; + if (!val) continue; const parsed = log.parseFilterValue(val); const negated = parsed.negate; const kids = []; const lead = chipLeadingSym(negated); - if (lead) - kids.push(lead); + if (lead) kids.push(lead); kids.push(E('span', { 'class': 'fwlive-chip-label' }, chipValueNodes(spec.label, val))); - kids.push(E('span', { - 'class': 'fwlive-chip-invert-wrap', - 'data-tip': negated ? _('Include instead') : _('Exclude instead') - }, [ - E('button', { - 'type': 'button', - 'class': 'fwlive-chip-invert', - 'click': function(ev) { callbacks.onInvert(spec.key, ev); } - }, [ '≠' ]) - ])); - kids.push(E('a', { - 'href': '#', - 'class': 'fwlive-chip-remove', - 'title': _('Remove filter'), - 'click': function(ev) { callbacks.onClear(spec.key, ev); } - }, [ '×' ])); + kids.push( + E( + 'span', + { + 'class': 'fwlive-chip-invert-wrap', + 'data-tip': negated ? _('Include instead') : _('Exclude instead') + }, + [ + E( + 'button', + { + 'type': 'button', + 'class': 'fwlive-chip-invert', + 'click': function (ev) { + callbacks.onInvert(spec.key, ev); + } + }, + ['≠'] + ) + ] + ) + ); + kids.push( + E( + 'a', + { + 'href': '#', + 'class': 'fwlive-chip-remove', + 'title': _('Remove filter'), + 'click': function (ev) { + callbacks.onClear(spec.key, ev); + } + }, + ['×'] + ) + ); - chips.push(E('span', { - 'class': 'fwlive-chip' - + (negated ? ' fwlive-chip-negated' : ' fwlive-chip-include') - }, kids)); + chips.push( + E( + 'span', + { + 'class': + 'fwlive-chip' + (negated ? ' fwlive-chip-negated' : ' fwlive-chip-include') + }, + kids + ) + ); } host.className = 'fwlive-chips fwlive-chips-labels'; @@ -106,14 +127,21 @@ function renderFilterChips(host, state, callbacks) { } host.style.display = 'flex'; - for (let i = 0; i < chips.length; i++) - host.appendChild(chips[i]); + for (let i = 0; i < chips.length; i++) host.appendChild(chips[i]); - host.appendChild(E('a', { - 'href': '#', - 'class': 'fwlive-chip-clear', - 'click': function(ev) { callbacks.onClearAll(ev); } - }, [ _('Clear all') ])); + host.appendChild( + E( + 'a', + { + 'href': '#', + 'class': 'fwlive-chip-clear', + 'click': function (ev) { + callbacks.onClearAll(ev); + } + }, + [_('Clear all')] + ) + ); } return baseclass.extend({ diff --git a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/constants.js b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/constants.js index b5ddb588a..27126b9f9 100644 --- a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/constants.js +++ b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/constants.js @@ -1,4 +1,6 @@ 'use strict'; +/* SPDX-License-Identifier: Apache-2.0 */ +/* Copyright 2025-2026 Lucas Albers */ 'require baseclass'; /** @@ -8,17 +10,32 @@ return baseclass.extend({ /* Keep in sync with openwrt-feed/luci-app-fwlive/Makefile PKG_VERSION. */ APP_VERSION: '0.1.38', - ROW_LIMIT_OPTIONS: [ 25, 50, 100, 250, 500, 1000, 2000 ], + ROW_LIMIT_OPTIONS: [25, 50, 100, 250, 500, 1000, 2000], DEFAULT_ROW_LIMIT: 100, /* Row pass/deny tint (#40): classic green/red default; accessible teal/orange */ - ROW_TINT_OPTIONS: [ 'off', 'classic', 'accessible' ], + ROW_TINT_OPTIONS: ['off', 'classic', 'accessible'], DEFAULT_ROW_TINT: 'classic', - FETCH_LINES_MAX: 2000, /* ubus poll / logd ring cap (~2000 lines ≈ typical ring) */ + FETCH_LINES_MAX: 2000 /* ubus poll / logd ring cap (~2000 lines ≈ typical ring) */, /* DOM budget: ~250 new/updated rows painted per second on typical LuCI routers */ RENDER_CAP_PER_SEC: 250, - VIEW_MODES: [ 'simple', 'detailed' ], + VIEW_MODES: ['simple', 'detailed'], COLUMN_SETS: { - simple: [ 'action', 'time', 'iface', 'flow', 'proto', 'rule' ], - detailed: [ 'time', 'action', 'rule', 'iface_in', 'iface_out', 'dir', 'proto', 'src', 'sport', 'dst', 'dport', 'flags', 'len', 'message' ] + simple: ['action', 'time', 'iface', 'flow', 'proto', 'rule'], + detailed: [ + 'time', + 'action', + 'rule', + 'iface_in', + 'iface_out', + 'dir', + 'proto', + 'src', + 'sport', + 'dst', + 'dport', + 'flags', + 'len', + 'message' + ] } }); diff --git a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/css.js b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/css.js index 304a2f821..16c8983e0 100644 --- a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/css.js +++ b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/css.js @@ -1,4 +1,6 @@ 'use strict'; +/* SPDX-License-Identifier: Apache-2.0 */ +/* Copyright 2025-2026 Lucas Albers */ 'require baseclass'; /** diff --git a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/hostname.js b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/hostname.js index 98d04adc6..7ab46332c 100644 --- a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/hostname.js +++ b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/hostname.js @@ -1,4 +1,6 @@ 'use strict'; +/* SPDX-License-Identifier: Apache-2.0 */ +/* Copyright 2025-2026 Lucas Albers */ 'require baseclass'; /** @@ -11,10 +13,9 @@ return baseclass.extend({ FAIL_MAX: 1000, /* Touch-on-write LRU: re-insert moves key to newest; evict oldest when over max. */ - lruSet: function(map, key, value, max) { + lruSet: function (map, key, value, max) { const cap = max || this.CACHE_MAX; - if (map.has(key)) - map.delete(key); + if (map.has(key)) map.delete(key); map.set(key, value); while (map.size > cap) { const oldest = map.keys().next().value; @@ -23,33 +24,30 @@ return baseclass.extend({ return map; }, - lruGet: function(map, key) { - if (!map.has(key)) - return undefined; + lruGet: function (map, key) { + if (!map.has(key)) return undefined; const value = map.get(key); map.delete(key); map.set(key, value); return value; }, - failIsHot: function(failedMap, ip, nowMs, ttlMs) { - if (!failedMap || !failedMap.has(ip)) - return false; + failIsHot: function (failedMap, ip, nowMs, ttlMs) { + if (!failedMap || !failedMap.has(ip)) return false; const at = failedMap.get(ip); const ttl = ttlMs == null ? this.FAIL_TTL_MS : ttlMs; const now = nowMs == null ? Date.now() : nowMs; - if ((now - at) >= ttl) { + if (now - at >= ttl) { failedMap.delete(ip); return false; } return true; }, - failMark: function(failedMap, ip, nowMs, max) { + failMark: function (failedMap, ip, nowMs, max) { const cap = max || this.FAIL_MAX; const now = nowMs == null ? Date.now() : nowMs; - if (failedMap.has(ip)) - failedMap.delete(ip); + if (failedMap.has(ip)) failedMap.delete(ip); failedMap.set(ip, now); while (failedMap.size > cap) { const oldest = failedMap.keys().next().value; diff --git a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/links.js b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/links.js index 2921c1175..e2f5ec2ee 100644 --- a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/links.js +++ b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/links.js @@ -1,4 +1,6 @@ 'use strict'; +/* SPDX-License-Identifier: Apache-2.0 */ +/* Copyright 2025-2026 Lucas Albers */ 'require baseclass'; /* LuCI require() needs Class.isSubclass — plain return {} fails */ 'require fwlive.log as log'; @@ -16,8 +18,7 @@ */ function luciUrl(path) { - if (typeof L !== 'undefined' && L.url) - return L.url(path); + if (typeof L !== 'undefined' && L.url) return L.url(path); return '/cgi-bin/luci/' + path; } @@ -31,10 +32,14 @@ function firewallZonesUrl() { } function firewallZonesLink(label) { - return E('a', { - 'href': firewallZonesUrl(), - 'class': 'fwlive-filter-link' - }, [ label || _('Network → Firewall') ]); + return E( + 'a', + { + 'href': firewallZonesUrl(), + 'class': 'fwlive-filter-link' + }, + [label || _('Network → Firewall')] + ); } /** @@ -44,15 +49,20 @@ function firewallZonesLink(label) { * @param {function} onFilterClick - callback(field, value, ev) */ function filterLink(field, value, label, onFilterClick) { - if (!value) - return log.formatCell(value); + if (!value) return log.formatCell(value); - return E('a', { - 'href': '#', - 'class': 'fwlive-filter-link', - 'title': _('Filter by %s').format(field), - 'click': function(ev) { onFilterClick(field, value, ev); } - }, [ label || value ]); + return E( + 'a', + { + 'href': '#', + 'class': 'fwlive-filter-link', + 'title': _('Filter by %s').format(field), + 'click': function (ev) { + onFilterClick(field, value, ev); + } + }, + [label || value] + ); } /** @@ -63,19 +73,24 @@ function filterLink(field, value, label, onFilterClick) { * @param {function} onFilterClick - callback(field, value, ev) */ function addrFilterLink(field, ip, showHostnames, hostnameCache, onFilterClick) { - if (!ip) - return log.formatCell(ip); + if (!ip) return log.formatCell(ip); const name = showHostnames && hostnameCache ? hostnameCache.get(ip) : null; const display = name || ip; const title = name ? ip : _('Filter by %s').format(field); - return E('a', { - 'href': '#', - 'class': 'fwlive-filter-link', - 'title': title, - 'click': function(ev) { onFilterClick(field, ip, ev); } - }, [ display ]); + return E( + 'a', + { + 'href': '#', + 'class': 'fwlive-filter-link', + 'title': title, + 'click': function (ev) { + onFilterClick(field, ip, ev); + } + }, + [display] + ); } /** @@ -83,11 +98,9 @@ function addrFilterLink(field, ip, showHostnames, hostnameCache, onFilterClick) * @param {string} firewallBackend - 'nft' or 'iptables' */ function ruleAdminPath(hint, firewallBackend) { - if (hint === 'fw4') - return 'admin/network/firewall/rules'; + if (hint === 'fw4') return 'admin/network/firewall/rules'; - if (firewallBackend === 'iptables') - return 'admin/status/iptables'; + if (firewallBackend === 'iptables') return 'admin/status/iptables'; return 'admin/status/nftables'; } @@ -99,27 +112,31 @@ function ruleAdminPath(hint, firewallBackend) { * @param {function} onFilterClick - callback(field, value, ev) */ function ruleAdminLink(hint, label, firewallBackend, onFilterClick) { - if (!hint) - return log.formatCell(hint); + if (!hint) return log.formatCell(hint); const path = ruleAdminPath(hint, firewallBackend); const url = '%s#%s'.format(luciUrl(path), encodeURIComponent(hint)); const text = label || hint; - return E('a', { - 'href': '#', - 'class': 'fwlive-filter-link fwlive-rule-link', - 'title': _('Filter logs by rule (hint: %s). Ctrl+click to open firewall settings.').format(hint), - 'click': function(ev) { - if (ev && (ev.ctrlKey || ev.metaKey)) { - if (ev.preventDefault) - ev.preventDefault(); - window.location = url; - return; + return E( + 'a', + { + 'href': '#', + 'class': 'fwlive-filter-link fwlive-rule-link', + 'title': _( + 'Filter logs by rule (hint: %s). Ctrl+click to open firewall settings.' + ).format(hint), + 'click': function (ev) { + if (ev && (ev.ctrlKey || ev.metaKey)) { + if (ev.preventDefault) ev.preventDefault(); + window.location = url; + return; + } + onFilterClick('q', hint, ev); } - onFilterClick('q', hint, ev); - } - }, [ text ]); + }, + [text] + ); } /** @@ -127,15 +144,20 @@ function ruleAdminLink(hint, label, firewallBackend, onFilterClick) { * @param {function} onFilterClick - callback(field, value, ev) */ function ifaceLink(value, onFilterClick) { - if (!value) - return log.formatCell(value); + if (!value) return log.formatCell(value); - return E('a', { - 'href': '#', - 'class': 'fwlive-filter-link fwlive-iface-badge', - 'title': _('Filter by interface'), - 'click': function(ev) { onFilterClick('interface', value, ev); } - }, [ value ]); + return E( + 'a', + { + 'href': '#', + 'class': 'fwlive-filter-link fwlive-iface-badge', + 'title': _('Filter by interface'), + 'click': function (ev) { + onFilterClick('interface', value, ev); + } + }, + [value] + ); } return baseclass.extend({ diff --git a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js index 7a20d73ad..2c1394481 100644 --- a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js +++ b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js @@ -1,4 +1,6 @@ 'use strict'; +/* SPDX-License-Identifier: Apache-2.0 */ +/* Copyright 2025-2026 Lucas Albers */ 'require baseclass'; /** @@ -8,236 +10,253 @@ */ return baseclass.extend({ CLASSIFY_SPEC: { - glueKeys: ['IN', 'OUT', 'SRC', 'DST', 'PROTO', 'SPT', 'DPT', 'LEN', 'MAC', 'TYPE', 'CODE', 'TTL', 'TOS', 'PREC', 'DF'], - nonFirewallPrefixes: ['dnsmasq', 'procd', 'ubusd', 'netifd', 'odhcpd', 'logd', 'dropbear', 'uhttpd', 'hostapd', 'wpad'], + glueKeys: [ + 'IN', + 'OUT', + 'SRC', + 'DST', + 'PROTO', + 'SPT', + 'DPT', + 'LEN', + 'MAC', + 'TYPE', + 'CODE', + 'TTL', + 'TOS', + 'PREC', + 'DF' + ], + nonFirewallPrefixes: [ + 'dnsmasq', + 'procd', + 'ubusd', + 'netifd', + 'odhcpd', + 'logd', + 'dropbear', + 'uhttpd', + 'hostapd', + 'wpad' + ], firewallHints: ['fw4', 'nft', 'iptables', 'kernel', 'firewall'], actionWords: ['ACCEPT', 'ALLOW', 'PASS', 'DROP', 'REJECT', 'DENY', 'BLOCK'], rules: [ - { or: [ - { and: [ { kv: ['SRC'] }, { kv: ['DST'] } ] }, - { and: [ { kvAny: ['IN', 'OUT'] }, { kvAny: ['SRC', 'DST', 'PROTO', 'SPT', 'DPT'] } ] }, - { and: [ { action: 'known' }, { kvAny: ['IN', 'OUT', 'PROTO', 'SRC', 'DST'] } ] } - ]}, - { and: [ { hint: true }, { action: 'known' } ] }, - { and: [ { hint: true }, { kvAny: ['IN', 'OUT', 'SRC', 'DST', 'PROTO'] } ] } + { + or: [ + { and: [{ kv: ['SRC'] }, { kv: ['DST'] }] }, + { + and: [ + { kvAny: ['IN', 'OUT'] }, + { kvAny: ['SRC', 'DST', 'PROTO', 'SPT', 'DPT'] } + ] + }, + { and: [{ action: 'known' }, { kvAny: ['IN', 'OUT', 'PROTO', 'SRC', 'DST'] }] } + ] + }, + { and: [{ hint: true }, { action: 'known' }] }, + { and: [{ hint: true }, { kvAny: ['IN', 'OUT', 'SRC', 'DST', 'PROTO'] }] } ] }, TCP_FLAG_TAIL: /\b(SYN|ACK|FIN|RST|PSH|URG)(?:\s+(?:SYN|ACK|FIN|RST|PSH|URG))*\s*$/i, - NETFILTER_KV_GLUE: /([^\s])(?=(IN|OUT|SRC|DST|PROTO|SPT|DPT|LEN|MAC|TYPE|CODE|TTL|TOS|PREC|DF)=)/g, + NETFILTER_KV_GLUE: + /([^\s])(?=(IN|OUT|SRC|DST|PROTO|SPT|DPT|LEN|MAC|TYPE|CODE|TTL|TOS|PREC|DF)=)/g, - wordPattern: function(words) { + wordPattern: function (words) { const alt = words.join('|'); return new RegExp('(^|[^A-Za-z0-9_])(' + alt + ')([^A-Za-z0-9_]|$)', 'i'); }, - kvHas: function(msg, key) { + kvHas: function (msg, key) { return new RegExp('(^|[^A-Za-z0-9_])' + key + '=').test(msg); }, - NON_FIREWALL_PREFIX: /^(dnsmasq|procd|ubusd|netifd|odhcpd|logd|dropbear|uhttpd|hostapd|wpad)([^A-Za-z0-9_]|$)/i, + NON_FIREWALL_PREFIX: + /^(dnsmasq|procd|ubusd|netifd|odhcpd|logd|dropbear|uhttpd|hostapd|wpad)([^A-Za-z0-9_]|$)/i, FIREWALL_HINT: /(^|[^A-Za-z0-9_])(fw4|nft|iptables|kernel|firewall)([^A-Za-z0-9_]|$)/i, ACTION_RE: /(^|[^A-Za-z0-9_])(ACCEPT|ALLOW|PASS|DROP|REJECT|DENY|BLOCK)([^A-Za-z0-9_]|$)/i, DENY_ACTION: /(^|[^A-Za-z0-9_])(DROP|REJECT|DENY|BLOCK)([^A-Za-z0-9_]|$)/i, - normalizeNetfilterMessage: function(message) { + normalizeNetfilterMessage: function (message) { return (message || '').replace(this.NETFILTER_KV_GLUE, '$1 '); }, - parseKeyValueLog: function(message) { + parseKeyValueLog: function (message) { const out = {}; const re = /\b([A-Z]+)=([^\s]+)/g; const normalized = this.normalizeNetfilterMessage(message); let match; - while ((match = re.exec(normalized)) !== null) - out[match[1]] = match[2]; + while ((match = re.exec(normalized)) !== null) out[match[1]] = match[2]; return out; }, - detectAction: function(message) { + detectAction: function (message) { const m = message.match(this.ACTION_RE); return m ? m[2].toUpperCase() : 'UNKNOWN'; }, - evaluateClassifySpec: function(message, actionRaw) { + evaluateClassifySpec: function (message, actionRaw) { const msg = this.normalizeNetfilterMessage(message || ''); const action = actionRaw === undefined ? this.detectAction(msg) : actionRaw; const self = this; - const has = function(key) { return self.kvHas(msg, key); }; - const hasAny = function(keys) { + const has = function (key) { + return self.kvHas(msg, key); + }; + const hasAny = function (keys) { for (let i = 0; i < keys.length; i++) { - if (has(keys[i])) - return true; + if (has(keys[i])) return true; } return false; }; const pred = { - kv: function(c) { + kv: function (c) { for (let i = 0; i < c.kv.length; i++) { - if (!has(c.kv[i])) - return false; + if (!has(c.kv[i])) return false; } return true; }, - kvAny: function(c) { return hasAny(c.kvAny); }, - action: function(c) { return (c.action === 'known') ? action !== 'UNKNOWN' : true; }, - hint: function() { return self.FIREWALL_HINT.test(msg); } + kvAny: function (c) { + return hasAny(c.kvAny); + }, + action: function (c) { + return c.action === 'known' ? action !== 'UNKNOWN' : true; + }, + hint: function () { + return self.FIREWALL_HINT.test(msg); + } }; - const evalNode = function(node) { + const evalNode = function (node) { if (node.and) { for (let i = 0; i < node.and.length; i++) { - if (!evalNode(node.and[i])) - return false; + if (!evalNode(node.and[i])) return false; } return true; } if (node.or) { for (let i = 0; i < node.or.length; i++) { - if (evalNode(node.or[i])) - return true; + if (evalNode(node.or[i])) return true; } return false; } const keys = Object.keys(node); for (let i = 0; i < keys.length; i++) { const k = keys[i]; - if (pred[k]) - return pred[k](node); + if (pred[k]) return pred[k](node); } return false; }; for (let i = 0; i < this.CLASSIFY_SPEC.rules.length; i++) { - if (evalNode(this.CLASSIFY_SPEC.rules[i])) - return true; + if (evalNode(this.CLASSIFY_SPEC.rules[i])) return true; } return false; }, - normalizeAction: function(raw) { + normalizeAction: function (raw) { const a = (raw || '').toUpperCase(); const words = this.CLASSIFY_SPEC.actionWords; const pass = words.slice(0, 3); /* ACCEPT|ALLOW|PASS */ const denyClass = words.slice(3); /* DROP|REJECT|DENY|BLOCK — positional */ - if (pass.indexOf(a) >= 0) - return 'pass'; - if (a === words[3]) /* DROP */ - return 'drop'; - if (a === words[4]) /* REJECT */ - return 'reject'; - if (denyClass.indexOf(a) >= 0) /* DENY|BLOCK */ - return 'block'; + if (pass.indexOf(a) >= 0) return 'pass'; + if (a === words[3]) /* DROP */ return 'drop'; + if (a === words[4]) /* REJECT */ return 'reject'; + if (denyClass.indexOf(a) >= 0) /* DENY|BLOCK */ return 'block'; return 'unknown'; }, - parseRuleHint: function(message) { + parseRuleHint: function (message) { let msg = this.normalizeNetfilterMessage(message || '').trim(); msg = msg.replace(/^\[\s*[\d.]+\]\s*/, ''); - if (/^fw4:\s*/i.test(msg)) - return 'fw4'; + if (/^fw4:\s*/i.test(msg)) return 'fw4'; const beforeKv = msg.match(/^([A-Za-z0-9_.-]+)(?::|\s+)(?=IN=|OUT=|SRC=|DST=|PROTO=)/); - if (beforeKv) - return beforeKv[1]; + if (beforeKv) return beforeKv[1]; const colon = msg.match(/^([A-Za-z0-9_.-]+):/); if (colon) { const tag = colon[1].toLowerCase(); - if (tag !== 'kernel' && tag !== 'iptables') - return colon[1]; + if (tag !== 'kernel' && tag !== 'iptables') return colon[1]; } return ''; }, - formatRuleLabel: function(hint) { - if (!hint) - return ''; + formatRuleLabel: function (hint) { + if (!hint) return ''; - if (hint === 'fw4') - return 'Firewall4'; + if (hint === 'fw4') return 'Firewall4'; return hint.replace(/-/g, ' '); }, - inferActionRaw: function(message, kv, actionRaw) { - if (actionRaw && actionRaw !== 'UNKNOWN') - return actionRaw; + inferActionRaw: function (message, kv, actionRaw) { + if (actionRaw && actionRaw !== 'UNKNOWN') return actionRaw; const msg = this.normalizeNetfilterMessage(message || ''); const withoutKv = msg.replace(/\b[A-Z]+=[^\s]*/g, ' '); - if (this.DENY_ACTION.test(withoutKv)) - return 'UNKNOWN'; + if (this.DENY_ACTION.test(withoutKv)) return 'UNKNOWN'; - if (/^kernel:/i.test(msg.trim())) - return 'UNKNOWN'; + if (/^kernel:/i.test(msg.trim())) return 'UNKNOWN'; const hasTuple = !!(kv.IN || kv.OUT) && !!(kv.SRC || kv.DST || kv.PROTO); - if (hasTuple) - return 'PASS'; + if (hasTuple) return 'PASS'; return 'UNKNOWN'; }, - parseFlags: function(message, kv) { - if (kv.TCPFLAGS) - return kv.TCPFLAGS; - if (kv.FLAGS) - return kv.FLAGS; + parseFlags: function (message, kv) { + if (kv.TCPFLAGS) return kv.TCPFLAGS; + if (kv.FLAGS) return kv.FLAGS; const m = message.match(this.TCP_FLAG_TAIL); - if (!m) - return ''; + if (!m) return ''; return m[0].trim().toUpperCase().replace(/\s+/g, ','); }, - parseLength: function(kv) { + parseLength: function (kv) { const len = kv.LEN || kv.LENGTH || ''; - if (!len) - return null; + if (!len) return null; const n = parseInt(len, 10); return isFinite(n) ? n : null; }, - timestampUnix: function(entry) { - if (!entry || entry.time == null || entry.time === '') - return null; + timestampUnix: function (entry) { + if (!entry || entry.time == null || entry.time === '') return null; if (typeof entry.time === 'string' && /^\d{4}-\d{2}-\d{2}[T ]/.test(entry.time)) { const ms = new Date(entry.time).getTime(); - if (isFinite(ms)) - return Math.floor(ms / 1000); + if (isFinite(ms)) return Math.floor(ms / 1000); } const n = Number(entry.time); - if (!isFinite(n)) - return null; + if (!isFinite(n)) return null; return n > 1e12 ? Math.floor(n / 1000) : Math.floor(n); }, - formatTimestampDisplay: function(entry) { + formatTimestampDisplay: function (entry) { const unix = this.timestampUnix(entry); - if (unix == null) - return ''; + if (unix == null) return ''; return new Date(unix * 1000).toISOString(); }, /* @fwlive-codegen:luci-preserve-begin */ - formatTimestampLocal: function(unix) { - if (unix == null || !isFinite(unix)) - return ''; + formatTimestampLocal: function (unix) { + if (unix == null || !isFinite(unix)) return ''; const d = new Date(unix * 1000); - const pad = function(n) { return (n < 10 ? '0' : '') + n; }; + const pad = function (n) { + return (n < 10 ? '0' : '') + n; + }; return '%d-%s-%s %s:%s:%s'.format( d.getFullYear(), @@ -249,17 +268,18 @@ return baseclass.extend({ ); }, - formatTimestampCompact: function(unix) { - if (unix == null || !isFinite(unix)) - return ''; + formatTimestampCompact: function (unix) { + if (unix == null || !isFinite(unix)) return ''; const d = new Date(unix * 1000); - const pad = function(n) { return (n < 10 ? '0' : '') + n; }; + const pad = function (n) { + return (n < 10 ? '0' : '') + n; + }; return '%s:%s:%s'.format(pad(d.getHours()), pad(d.getMinutes()), pad(d.getSeconds())); }, - formatFlowDisplay: function(row) { + formatFlowDisplay: function (row) { const src = row && row.src ? String(row.src) : ''; const dst = row && row.dst ? String(row.dst) : ''; const sport = row && row.sport ? String(row.sport) : ''; @@ -267,75 +287,89 @@ return baseclass.extend({ let left = src; let right = dst; - if (sport) - left = left ? (left + ':' + sport) : (':' + sport); - if (dport) - right = right ? (right + ':' + dport) : (':' + dport); + if (sport) left = left ? left + ':' + sport : ':' + sport; + if (dport) right = right ? right + ':' + dport : ':' + dport; - if (!left && !right) - return '—'; - if (!right) - return left; - if (!left) - return '→ ' + right; + if (!left && !right) return '—'; + if (!right) return left; + if (!left) return '→ ' + right; return left + ' → ' + right; }, - formatCell: function(value) { - if (value == null || value === '') - return ''; + formatCell: function (value) { + if (value == null || value === '') return ''; return String(value); }, - formatActionLabel: function(action) { + formatActionLabel: function (action) { const a = (action || '').toLowerCase(); - if (!a || a === 'unknown') - return '—'; + if (!a || a === 'unknown') return '—'; return a; }, - formatMessageDisplay: function(message, layout) { + formatMessageDisplay: function (message, layout) { let m = this.normalizeNetfilterMessage(message || ''); m = m.replace(/^\[\s*[\d.]+\]\s*/, ''); m = m.replace(/\bMAC=[^\s]+/g, ''); m = m.replace(/\s+/g, ' ').trim(); - if (layout === 'oneline') - return m; + if (layout === 'oneline') return m; - if (m.length > 240) - return m.substring(0, 237) + '…'; + if (m.length > 240) return m.substring(0, 237) + '…'; return m; }, /* @fwlive-codegen:luci-preserve-end */ - isFirewallEvent: function(entry) { + isFirewallEvent: function (entry) { const msg = this.normalizeNetfilterMessage((entry && entry.msg) || ''); - if (!msg.trim()) - return false; + if (!msg.trim()) return false; - if (this.NON_FIREWALL_PREFIX.test(msg)) - return false; + if (this.NON_FIREWALL_PREFIX.test(msg)) return false; return this.evaluateClassifySpec(msg); }, - makeEntryId: function(entry, tsUnix, action, src, dst, sport, dport, proto, ifaceIn, ifaceOut) { - if (entry && entry.id != null && entry.id !== '') - return 'log:' + entry.id; + makeEntryId: function ( + entry, + tsUnix, + action, + src, + dst, + sport, + dport, + proto, + ifaceIn, + ifaceOut + ) { + if (entry && entry.id != null && entry.id !== '') return 'log:' + entry.id; - return [tsUnix, action, src, dst, sport, dport, proto, ifaceIn, ifaceOut, entry.msg || ''].join('|'); + return [ + tsUnix, + action, + src, + dst, + sport, + dport, + proto, + ifaceIn, + ifaceOut, + entry.msg || '' + ].join('|'); }, - normalizeEntry: function(entry) { + normalizeEntry: function (entry) { const kv = this.parseKeyValueLog(entry.msg || ''); const tsUnix = this.timestampUnix(entry); const tsDisplay = this.formatTimestampDisplay(entry); const proto = (kv.PROTO || '').toUpperCase(); - const actionRaw = this.inferActionRaw(entry.msg || '', kv, this.detectAction(entry.msg || '')); + const actionRaw = this.inferActionRaw( + entry.msg || '', + kv, + this.detectAction(entry.msg || '') + ); const action = this.normalizeAction(actionRaw); const src = kv.SRC || ''; const dst = kv.DST || ''; @@ -344,14 +378,25 @@ return baseclass.extend({ const ifaceIn = kv.IN || ''; const ifaceOut = kv.OUT || ''; const iface = ifaceIn || ifaceOut || ''; - const dir = ifaceIn && ifaceOut ? 'forward' : (ifaceIn ? 'in' : (ifaceOut ? 'out' : 'unknown')); + const dir = ifaceIn && ifaceOut ? 'forward' : ifaceIn ? 'in' : ifaceOut ? 'out' : 'unknown'; const flags = this.parseFlags(entry.msg || '', kv); const length = this.parseLength(kv); const ruleHint = this.parseRuleHint(entry.msg || ''); const ruleLabel = this.formatRuleLabel(ruleHint); return { - id: this.makeEntryId(entry, tsUnix, action, src, dst, sport, dport, proto, ifaceIn, ifaceOut), + id: this.makeEntryId( + entry, + tsUnix, + action, + src, + dst, + sport, + dport, + proto, + ifaceIn, + ifaceOut + ), log_id: entry && entry.id != null ? Number(entry.id) : null, timestamp: tsUnix, timestamp_display: tsDisplay, @@ -374,29 +419,25 @@ return baseclass.extend({ }; }, - parseFilterValue: function(val) { + parseFilterValue: function (val) { const s = (val || '').trim(); - if (!s) - return { negate: false, value: '' }; + if (!s) return { negate: false, value: '' }; - if (s.charAt(0) === '!') - return { negate: true, value: s.slice(1).trim() }; + if (s.charAt(0) === '!') return { negate: true, value: s.slice(1).trim() }; return { negate: false, value: s }; }, - toggleFilterNegation: function(val) { + toggleFilterNegation: function (val) { const p = this.parseFilterValue(val); - if (!p.value) - return val; + if (!p.value) return val; return p.negate ? p.value : '!' + p.value; }, - formatFilterChipLabel: function(field, val) { + formatFilterChipLabel: function (field, val) { const p = this.parseFilterValue(val); - if (!p.value) - return ''; + if (!p.value) return ''; if (p.negate) { if (field === 'q' || field === 'src' || field === 'dst') @@ -408,19 +449,17 @@ return baseclass.extend({ return '%s: %s'.format(field, val); }, - matchesTextField: function(haystack, spec) { + matchesTextField: function (haystack, spec) { const p = this.parseFilterValue(spec); - if (!p.value) - return true; + if (!p.value) return true; const hit = (haystack || '').indexOf(p.value) !== -1; return p.negate ? !hit : hit; }, - matchesExactField: function(haystack, spec) { + matchesExactField: function (haystack, spec) { const p = this.parseFilterValue(spec); - if (!p.value) - return true; + if (!p.value) return true; const want = p.value.toUpperCase(); const got = (haystack || '').toUpperCase(); @@ -428,18 +467,16 @@ return baseclass.extend({ return p.negate ? !hit : hit; }, - matchesFilter: function(row, filters) { + matchesFilter: function (row, filters) { if (filters.q) { const p = this.parseFilterValue(filters.q); if (p.value) { const keys = Object.keys(row); const parts = []; - for (let i = 0; i < keys.length; i++) - parts.push(row[keys[i]]); + for (let i = 0; i < keys.length; i++) parts.push(row[keys[i]]); const blob = parts.join(' ').toLowerCase(); const hit = blob.indexOf(p.value.toLowerCase()) !== -1; - if (p.negate ? hit : !hit) - return false; + if (p.negate ? hit : !hit) return false; } } @@ -447,10 +484,10 @@ return baseclass.extend({ const p = this.parseFilterValue(filters.action); if (p.value) { const want = p.value.toLowerCase(); - const hit = row.action === want - || (row.action_raw || '').toUpperCase() === p.value.toUpperCase(); - if (p.negate ? hit : !hit) - return false; + const hit = + row.action === want || + (row.action_raw || '').toUpperCase() === p.value.toUpperCase(); + if (p.negate ? hit : !hit) return false; } } @@ -458,33 +495,26 @@ return baseclass.extend({ const p = this.parseFilterValue(filters.interface); if (p.value) { const iface = p.value; - const hit = row.interface === iface - || row.interface_in === iface - || row.interface_out === iface; - if (p.negate ? hit : !hit) - return false; + const hit = + row.interface === iface || + row.interface_in === iface || + row.interface_out === iface; + if (p.negate ? hit : !hit) return false; } } - if (filters.proto && !this.matchesExactField(row.proto, filters.proto)) - return false; - if (filters.src && !this.matchesTextField(row.src, filters.src)) - return false; - if (filters.dst && !this.matchesTextField(row.dst, filters.dst)) - return false; - if (filters.sport && !this.matchesExactField(row.sport, filters.sport)) - return false; - if (filters.dport && !this.matchesExactField(row.dport, filters.dport)) - return false; + if (filters.proto && !this.matchesExactField(row.proto, filters.proto)) return false; + if (filters.src && !this.matchesTextField(row.src, filters.src)) return false; + if (filters.dst && !this.matchesTextField(row.dst, filters.dst)) return false; + if (filters.sport && !this.matchesExactField(row.sport, filters.sport)) return false; + if (filters.dport && !this.matchesExactField(row.dport, filters.dport)) return false; return true; }, - actionRowClass: function(action) { + actionRowClass: function (action) { const a = (action || '').toLowerCase(); - if (a === 'drop' || a === 'reject' || a === 'block') - return 'fwlive-action fwlive-deny'; - if (a === 'pass') - return 'fwlive-action fwlive-pass'; + if (a === 'drop' || a === 'reject' || a === 'block') return 'fwlive-action fwlive-deny'; + if (a === 'pass') return 'fwlive-action fwlive-pass'; return 'fwlive-action fwlive-unknown'; } }); diff --git a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js index 577ddeec1..abdd79814 100644 --- a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js +++ b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js @@ -1,4 +1,6 @@ 'use strict'; +/* SPDX-License-Identifier: Apache-2.0 */ +/* Copyright 2025-2026 Lucas Albers */ 'require baseclass'; 'require fwlive.links as links'; @@ -46,10 +48,11 @@ function persistConsentDismissed() { function blockerCode(state) { const blockers = (state.loggingStatus && state.loggingStatus.blockers) || []; - if (blockers.indexOf('no_wan_zone') >= 0) - return 'no_wan_zone'; - if (blockers.indexOf('nf_log_ipv4_missing') >= 0 || - blockers.indexOf('nf_log_ipv6_missing') >= 0) + if (blockers.indexOf('no_wan_zone') >= 0) return 'no_wan_zone'; + if ( + blockers.indexOf('nf_log_ipv4_missing') >= 0 || + blockers.indexOf('nf_log_ipv6_missing') >= 0 + ) return 'nf_log_missing'; return ''; } @@ -66,15 +69,21 @@ function renderToolbar(host, state, callbacks) { const blocker = blockerCode(state); if (blocker === 'no_wan_zone') { - host.appendChild(E('span', { 'class': 'fwlive-logging-status' }, - [ _('WAN logging unavailable: no WAN zone') ])); + host.appendChild( + E('span', { 'class': 'fwlive-logging-status' }, [ + _('WAN logging unavailable: no WAN zone') + ]) + ); host.appendChild(links.firewallZonesLink()); return; } if (blocker === 'nf_log_missing') { - host.appendChild(E('span', { 'class': 'fwlive-logging-status' }, - [ _('WAN logging unavailable: missing kernel log modules') ])); + host.appendChild( + E('span', { 'class': 'fwlive-logging-status' }, [ + _('WAN logging unavailable: missing kernel log modules') + ]) + ); return; } @@ -82,48 +91,64 @@ function renderToolbar(host, state, callbacks) { if (st.wan_log) { const busy = !!state.loggingBusy; const children = busy - ? [ _('Disabling…') ] + ? [_('Disabling…')] : [ - E('span', { 'class': 'fwlive-log-on-dot', 'aria-hidden': 'true' }, [ '' ]), - E('span', { 'class': 'fwlive-log-label' }, [ _('WAN logging on') ]), - E('span', { 'class': 'fwlive-log-rate' }, [ _('· %s').format(limit) ]) - ]; - host.appendChild(E('button', { - 'class': 'cbi-button fwlive-log-merged', - 'type': 'button', - 'title': _('WAN logging on (%s). Click to disable.').format(limit), - 'disabled': busy ? '' : null, - 'click': function() { callbacks.onDisable(); } - }, children)); + E('span', { 'class': 'fwlive-log-on-dot', 'aria-hidden': 'true' }, ['']), + E('span', { 'class': 'fwlive-log-label' }, [_('WAN logging on')]), + E('span', { 'class': 'fwlive-log-rate' }, [_('· %s').format(limit)]) + ]; + host.appendChild( + E( + 'button', + { + 'class': 'cbi-button fwlive-log-merged', + 'type': 'button', + 'title': _('WAN logging on (%s). Click to disable.').format(limit), + 'disabled': busy ? '' : null, + 'click': function () { + callbacks.onDisable(); + } + }, + children + ) + ); return; } - host.appendChild(E('button', { - 'class': 'cbi-button cbi-button-action', - 'type': 'button', - 'title': _('Enable WAN zone drop/reject logging (same as Network → Firewall).'), - 'disabled': state.loggingBusy ? '' : null, - 'click': function() { callbacks.onEnable(); } - }, [ state.loggingBusy ? _('Enabling…') : _('Enable logging') ])); + host.appendChild( + E( + 'button', + { + 'class': 'cbi-button cbi-button-action', + 'type': 'button', + 'title': _('Enable WAN zone drop/reject logging (same as Network → Firewall).'), + 'disabled': state.loggingBusy ? '' : null, + 'click': function () { + callbacks.onEnable(); + } + }, + [state.loggingBusy ? _('Enabling…') : _('Enable logging')] + ) + ); } function buildConsentPanel(state, callbacks) { const dontShowId = 'fwlive-consent-dont-show'; const panel = E('div', { 'class': 'fwlive-consent', 'id': 'fwlive-consent' }, [ - E('p', { 'class': 'fwlive-empty-title' }, [ _('Before you enable logging') ]), + E('p', { 'class': 'fwlive-empty-title' }, [_('Before you enable logging')]), E('ul', { 'class': 'fwlive-consent-list' }, [ E('li', {}, [ - E('strong', {}, [ _('Changes:') ]), + E('strong', {}, [_('Changes:')]), ' ', _('sets log on the WAN firewall zone and reloads the firewall.') ]), E('li', {}, [ - E('strong', {}, [ _('Does not change:') ]), + E('strong', {}, [_('Does not change:')]), ' ', _('allow/deny rules, LAN logging, or anything else.') ]), E('li', {}, [ - E('strong', {}, [ _('Undo:') ]), + E('strong', {}, [_('Undo:')]), ' ', _('turn it back off with the WAN logging on control on the watch strip.') ]) @@ -139,28 +164,34 @@ function buildConsentPanel(state, callbacks) { ]) ]), E('p', { 'class': 'fwlive-consent-actions' }, [ - E('button', { - 'class': 'cbi-button cbi-button-action', - 'type': 'button', - 'disabled': state.loggingBusy ? '' : null, - 'click': function() { - persistConsentDismissed(); - callbacks.onEnable(); - } - }, [ state.loggingBusy ? _('Enabling…') : _('Enable WAN drop/reject logging') ]), - ' ', - E('button', { - 'class': 'cbi-button', - 'type': 'button', - 'click': function() { - const box = document.getElementById(dontShowId); - const persist = !!(box && box.checked); - if (persist) + E( + 'button', + { + 'class': 'cbi-button cbi-button-action', + 'type': 'button', + 'disabled': state.loggingBusy ? '' : null, + 'click': function () { persistConsentDismissed(); - if (callbacks.onDismissConsent) - callbacks.onDismissConsent(persist); - } - }, [ _('Not now') ]), + callbacks.onEnable(); + } + }, + [state.loggingBusy ? _('Enabling…') : _('Enable WAN drop/reject logging')] + ), + ' ', + E( + 'button', + { + 'class': 'cbi-button', + 'type': 'button', + 'click': function () { + const box = document.getElementById(dontShowId); + const persist = !!(box && box.checked); + if (persist) persistConsentDismissed(); + if (callbacks.onDismissConsent) callbacks.onDismissConsent(persist); + } + }, + [_('Not now')] + ), ' ', links.firewallZonesLink(_('I’ll configure this under Network → Firewall')) ]) @@ -174,41 +205,71 @@ function buildEmptyStateNodes(state, callbacks) { const blocker = blockerCode(state); if (state.loggingNotice) { - nodes.push(E('p', { 'class': 'fwlive-logging-notice' }, [ - state.loggingNotice, - ' ', - links.firewallZonesLink() - ])); + nodes.push( + E('p', { 'class': 'fwlive-logging-notice' }, [ + state.loggingNotice, + ' ', + links.firewallZonesLink() + ]) + ); } if (blocker === 'no_wan_zone') { - nodes.push(E('p', { 'class': 'fwlive-empty-title' }, [ _('No WAN zone found') ])); - nodes.push(E('p', {}, [ - _('No WAN firewall zone found in /etc/config/firewall. Configure zones under '), - links.firewallZonesLink() - ])); + nodes.push(E('p', { 'class': 'fwlive-empty-title' }, [_('No WAN zone found')])); + nodes.push( + E('p', {}, [ + _('No WAN firewall zone found in /etc/config/firewall. Configure zones under '), + links.firewallZonesLink() + ]) + ); return nodes; } if (blocker === 'nf_log_missing') { - nodes.push(E('p', { 'class': 'fwlive-empty-title' }, [ _('Kernel log modules missing') ])); - nodes.push(E('p', {}, [ _('Kernel netfilter log modules are missing. Install kmod-nf-log-ipv4 and kmod-nf-log-ipv6 (or kmod-nf-log / kmod-nf-log6), then reload the firewall.') ])); - nodes.push(E('p', {}, [ - E('code', {}, [ 'opkg update && opkg install kmod-nf-log-ipv4 kmod-nf-log-ipv6' ]) - ])); + nodes.push(E('p', { 'class': 'fwlive-empty-title' }, [_('Kernel log modules missing')])); + nodes.push( + E('p', {}, [ + _( + 'Kernel netfilter log modules are missing. Install kmod-nf-log-ipv4 and kmod-nf-log-ipv6 (or kmod-nf-log / kmod-nf-log6), then reload the firewall.' + ) + ]) + ); + nodes.push( + E('p', {}, [ + E('code', {}, ['opkg update && opkg install kmod-nf-log-ipv4 kmod-nf-log-ipv6']) + ]) + ); return nodes; } if (st && st.wan_log) { - nodes.push(E('p', { 'class': 'fwlive-empty-title' }, [ _('Waiting for firewall events') ])); - nodes.push(E('p', {}, [ _('WAN drop/reject logging is on. Blocked inbound WAN traffic will show up here. Normal LAN browsing will not.') ])); - nodes.push(E('p', { 'class': 'fwlive-empty-muted' }, [ _('If the WAN is quiet, wait for probes or use the optional ping check in Help / the enabling-logs guide.') ])); + nodes.push(E('p', { 'class': 'fwlive-empty-title' }, [_('Waiting for firewall events')])); + nodes.push( + E('p', {}, [ + _( + 'WAN drop/reject logging is on. Blocked inbound WAN traffic will show up here. Normal LAN browsing will not.' + ) + ]) + ); + nodes.push( + E('p', { 'class': 'fwlive-empty-muted' }, [ + _( + 'If the WAN is quiet, wait for probes or use the optional ping check in Help / the enabling-logs guide.' + ) + ]) + ); nodes.push(E('p', {}, links.firewallZonesLink(_('Open firewall zone settings')))); return nodes; } - nodes.push(E('p', { 'class': 'fwlive-empty-title' }, [ _('Logging is off on this router') ])); - nodes.push(E('p', {}, [ _('OpenWrt does not write firewall events to the log until you turn logging on. Live View only shows what the firewall already logs — it does not add allow/deny rules.') ])); + nodes.push(E('p', { 'class': 'fwlive-empty-title' }, [_('Logging is off on this router')])); + nodes.push( + E('p', {}, [ + _( + 'OpenWrt does not write firewall events to the log until you turn logging on. Live View only shows what the firewall already logs — it does not add allow/deny rules.' + ) + ]) + ); /* Consent bullets already spell out the effect — do not repeat it or the CTA. */ if (state.showConsent) { @@ -216,29 +277,42 @@ function buildEmptyStateNodes(state, callbacks) { return nodes; } - nodes.push(E('p', {}, [ _('Turns on WAN zone drop/reject logging (same as Network → Firewall → wan → Log). Rate-limited by the zone log_limit (OpenWrt default 10/minute). Normal LAN browsing is not logged.') ])); - nodes.push(E('p', { 'class': 'fwlive-empty-muted' }, [ _('Nothing changes until you click Enable.') ])); - nodes.push(E('p', {}, [ - E('button', { - 'class': 'cbi-button cbi-button-action', - 'type': 'button', - 'disabled': state.loggingBusy ? '' : null, - 'click': function() { - persistConsentDismissed(); - callbacks.onEnable(); - } - }, [ state.loggingBusy ? _('Enabling…') : _('Enable WAN drop/reject logging') ]), - ' ', - links.firewallZonesLink(_('I’ll configure this under Network → Firewall')) - ])); + nodes.push( + E('p', {}, [ + _( + 'Turns on WAN zone drop/reject logging (same as Network → Firewall → wan → Log). Rate-limited by the zone log_limit (OpenWrt default 10/minute). Normal LAN browsing is not logged.' + ) + ]) + ); + nodes.push( + E('p', { 'class': 'fwlive-empty-muted' }, [_('Nothing changes until you click Enable.')]) + ); + nodes.push( + E('p', {}, [ + E( + 'button', + { + 'class': 'cbi-button cbi-button-action', + 'type': 'button', + 'disabled': state.loggingBusy ? '' : null, + 'click': function () { + persistConsentDismissed(); + callbacks.onEnable(); + } + }, + [state.loggingBusy ? _('Enabling…') : _('Enable WAN drop/reject logging')] + ), + ' ', + links.firewallZonesLink(_('I’ll configure this under Network → Firewall')) + ]) + ); return nodes; } function renderEmptyState(host, state, callbacks) { const nodes = buildEmptyStateNodes(state, callbacks); host.innerHTML = ''; - for (let i = 0; i < nodes.length; i++) - host.appendChild(nodes[i]); + for (let i = 0; i < nodes.length; i++) host.appendChild(nodes[i]); } /** @@ -250,11 +324,19 @@ function renderManualTestNodes(host, state, _callbacks) { host.innerHTML = ''; if (state.firewallBackend === 'iptables') { host.appendChild(document.createTextNode(_('Manual test (System → Terminal): '))); - host.appendChild(E('code', {}, [ 'iptables -I INPUT -p icmp --icmp-type echo-request -j LOG --log-prefix "fwlive-ping: "' ])); + host.appendChild( + E('code', {}, [ + 'iptables -I INPUT -p icmp --icmp-type echo-request -j LOG --log-prefix "fwlive-ping: "' + ]) + ); host.appendChild(document.createTextNode(_(' then ping the router.'))); } else { host.appendChild(document.createTextNode(_('Manual test (System → Terminal): '))); - host.appendChild(E('code', {}, [ 'nft insert rule inet fw4 input ip protocol icmp icmp type echo-request log prefix "fwlive-ping " accept' ])); + host.appendChild( + E('code', {}, [ + 'nft insert rule inet fw4 input ip protocol icmp icmp type echo-request log prefix "fwlive-ping " accept' + ]) + ); host.appendChild(document.createTextNode(_(' then ping the router.'))); } } diff --git a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/proto.js b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/proto.js index 45e97a080..cf5789a2b 100644 --- a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/proto.js +++ b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/proto.js @@ -1,4 +1,6 @@ 'use strict'; +/* SPDX-License-Identifier: Apache-2.0 */ +/* Copyright 2025-2026 Lucas Albers */ 'require baseclass'; /* LuCI require() needs Class.isSubclass — plain return {} fails */ /** @@ -7,25 +9,23 @@ */ return baseclass.extend({ - readProtoFilter: function() { + readProtoFilter: function () { const custom = document.getElementById('fwlive-proto-custom'); if (custom) { const typed = (custom.value || '').trim(); - if (typed) - return typed; + if (typed) return typed; } const sel = document.getElementById('fwlive-proto'); - return sel ? (sel.value || '') : ''; + return sel ? sel.value || '' : ''; }, - setProtoFilterValue: function(value) { + setProtoFilterValue: function (value) { const sel = document.getElementById('fwlive-proto'); const custom = document.getElementById('fwlive-proto-custom'); - if (!sel) - return false; + if (!sel) return false; value = value || ''; - let inMenu = (value === ''); + let inMenu = value === ''; if (!inMenu) { for (let i = 0; i < sel.options.length; i++) { if (sel.options[i].value === value) { @@ -37,12 +37,10 @@ return baseclass.extend({ if (inMenu) { sel.value = value; - if (custom) - custom.value = ''; + if (custom) custom.value = ''; } else { sel.value = ''; - if (custom) - custom.value = value; + if (custom) custom.value = value; } return true; diff --git a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js index 3da1924ef..83e25a091 100644 --- a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js +++ b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js @@ -1,4 +1,6 @@ 'use strict'; +/* SPDX-License-Identifier: Apache-2.0 */ +/* Copyright 2025-2026 Lucas Albers */ 'require baseclass'; 'require fwlive.log as log'; 'require fwlive.links as links'; @@ -51,23 +53,36 @@ function columnLabel(col) { function columnCellClass(col) { switch (col) { - case 'time': return 'fwlive-time'; - case 'action': return 'fwlive-action'; - case 'rule': return 'fwlive-rule'; - case 'iface': - case 'iface_in': - case 'iface_out': return 'fwlive-iface'; - case 'dir': return 'fwlive-dir'; - case 'proto': return 'fwlive-proto'; - case 'src': - case 'dst': return 'fwlive-addr'; - case 'sport': - case 'dport': return 'fwlive-port'; - case 'flags': return 'fwlive-flags'; - case 'len': return 'fwlive-len'; - case 'flow': return 'fwlive-flow-cell'; - case 'message': return 'fwlive-message fwlive-th-message'; - default: return ''; + case 'time': + return 'fwlive-time'; + case 'action': + return 'fwlive-action'; + case 'rule': + return 'fwlive-rule'; + case 'iface': + case 'iface_in': + case 'iface_out': + return 'fwlive-iface'; + case 'dir': + return 'fwlive-dir'; + case 'proto': + return 'fwlive-proto'; + case 'src': + case 'dst': + return 'fwlive-addr'; + case 'sport': + case 'dport': + return 'fwlive-port'; + case 'flags': + return 'fwlive-flags'; + case 'len': + return 'fwlive-len'; + case 'flow': + return 'fwlive-flow-cell'; + case 'message': + return 'fwlive-message fwlive-th-message'; + default: + return ''; } } @@ -75,26 +90,30 @@ function flowCell(row, state, callbacks) { const parts = []; const onFilterClick = callbacks.onFilterClick; const pushAddr = (addr, port, addrField, portField) => { - if (!addr && !port) - return; + if (!addr && !port) return; if (addr) - parts.push(links.addrFilterLink(addrField, addr, - !!state.showHostnames, state.hostnameCache, onFilterClick)); + parts.push( + links.addrFilterLink( + addrField, + addr, + !!state.showHostnames, + state.hostnameCache, + onFilterClick + ) + ); if (port) { - if (addr) - parts.push(':'); + if (addr) parts.push(':'); parts.push(links.filterLink(portField, port, port, onFilterClick)); } }; pushAddr(row.src, row.sport, 'src', 'sport'); if (parts.length && (row.dst || row.dport)) - parts.push(E('span', { 'class': 'fwlive-flow-arrow' }, [ ' → ' ])); + parts.push(E('span', { 'class': 'fwlive-flow-arrow' }, [' → '])); pushAddr(row.dst, row.dport, 'dst', 'dport'); - if (!parts.length) - return '—'; + if (!parts.length) return '—'; return E('span', { 'class': 'fwlive-flow' }, parts); } @@ -102,76 +121,120 @@ function flowCell(row, state, callbacks) { function buildColumnCell(col, row, state, callbacks) { const onFilterClick = callbacks.onFilterClick; const msgDisplay = log.formatMessageDisplay(row.message, state.messageLayout); - const actionCell = row.action && row.action !== 'unknown' - ? links.filterLink('action', row.action, log.formatActionLabel(row.action), onFilterClick) - : log.formatActionLabel(row.action); + const actionCell = + row.action && row.action !== 'unknown' + ? links.filterLink( + 'action', + row.action, + log.formatActionLabel(row.action), + onFilterClick + ) + : log.formatActionLabel(row.action); switch (col) { - case 'time': { - const timeAttrs = { 'class': columnCellClass(col) }; - if (state.viewMode === 'simple') - timeAttrs.title = _('Click a row for the full message'); - return E('td', timeAttrs, - [ state.viewMode === 'simple' - ? log.formatTimestampCompact(row.timestamp) - : log.formatTimestampLocal(row.timestamp) ]); - } - case 'action': - return E('td', { 'class': log.actionRowClass(row.action) }, [ actionCell ]); - case 'rule': - return E('td', { 'class': columnCellClass(col) }, - [ links.ruleAdminLink(row.rule_hint, row.rule_label, state.firewallBackend, onFilterClick) ]); - case 'iface': - return E('td', { 'class': columnCellClass(col) }, - [ links.ifaceLink(row.interface_in, onFilterClick) ]); - case 'iface_in': - case 'iface_out': - return E('td', { 'class': columnCellClass(col) }, [ links.ifaceLink( - col === 'iface_in' ? row.interface_in : row.interface_out, onFilterClick) ]); - case 'dir': - return E('td', { 'class': columnCellClass(col) }, [ log.formatCell(row.direction) ]); - case 'proto': - return E('td', { 'class': columnCellClass(col) }, - [ links.filterLink('proto', row.proto, null, onFilterClick) ]); - case 'src': - return E('td', { 'class': columnCellClass(col) }, - [ links.addrFilterLink('src', row.src, !!state.showHostnames, state.hostnameCache, onFilterClick) ]); - case 'sport': - return E('td', { 'class': columnCellClass(col) }, - [ links.filterLink('sport', row.sport, null, onFilterClick) ]); - case 'dst': - return E('td', { 'class': columnCellClass(col) }, - [ links.addrFilterLink('dst', row.dst, !!state.showHostnames, state.hostnameCache, onFilterClick) ]); - case 'dport': - return E('td', { 'class': columnCellClass(col) }, - [ links.filterLink('dport', row.dport, null, onFilterClick) ]); - case 'flags': - return E('td', { 'class': columnCellClass(col) }, [ log.formatCell(row.flags) ]); - case 'len': - return E('td', { 'class': columnCellClass(col) }, [ row.length != null ? String(row.length) : '' ]); - case 'flow': - return E('td', { 'class': columnCellClass(col) }, [ flowCell(row, state, callbacks) ]); - case 'message': - if (state.messageLayout === 'wrap') { - return E('td', { - 'class': 'fwlive-message', - 'title': msgDisplay || '' - }, E('div', { 'class': 'fwlive-message-wrap' }, [ msgDisplay || '—' ])); + case 'time': { + const timeAttrs = { 'class': columnCellClass(col) }; + if (state.viewMode === 'simple') + timeAttrs.title = _('Click a row for the full message'); + return E('td', timeAttrs, [ + state.viewMode === 'simple' + ? log.formatTimestampCompact(row.timestamp) + : log.formatTimestampLocal(row.timestamp) + ]); } - return E('td', { - 'class': 'fwlive-message', - 'title': msgDisplay || '' - }, [ msgDisplay || '—' ]); - default: - return E('td', {}, [ '' ]); + case 'action': + return E('td', { 'class': log.actionRowClass(row.action) }, [actionCell]); + case 'rule': + return E('td', { 'class': columnCellClass(col) }, [ + links.ruleAdminLink( + row.rule_hint, + row.rule_label, + state.firewallBackend, + onFilterClick + ) + ]); + case 'iface': + return E('td', { 'class': columnCellClass(col) }, [ + links.ifaceLink(row.interface_in, onFilterClick) + ]); + case 'iface_in': + case 'iface_out': + return E('td', { 'class': columnCellClass(col) }, [ + links.ifaceLink( + col === 'iface_in' ? row.interface_in : row.interface_out, + onFilterClick + ) + ]); + case 'dir': + return E('td', { 'class': columnCellClass(col) }, [log.formatCell(row.direction)]); + case 'proto': + return E('td', { 'class': columnCellClass(col) }, [ + links.filterLink('proto', row.proto, null, onFilterClick) + ]); + case 'src': + return E('td', { 'class': columnCellClass(col) }, [ + links.addrFilterLink( + 'src', + row.src, + !!state.showHostnames, + state.hostnameCache, + onFilterClick + ) + ]); + case 'sport': + return E('td', { 'class': columnCellClass(col) }, [ + links.filterLink('sport', row.sport, null, onFilterClick) + ]); + case 'dst': + return E('td', { 'class': columnCellClass(col) }, [ + links.addrFilterLink( + 'dst', + row.dst, + !!state.showHostnames, + state.hostnameCache, + onFilterClick + ) + ]); + case 'dport': + return E('td', { 'class': columnCellClass(col) }, [ + links.filterLink('dport', row.dport, null, onFilterClick) + ]); + case 'flags': + return E('td', { 'class': columnCellClass(col) }, [log.formatCell(row.flags)]); + case 'len': + return E('td', { 'class': columnCellClass(col) }, [ + row.length != null ? String(row.length) : '' + ]); + case 'flow': + return E('td', { 'class': columnCellClass(col) }, [flowCell(row, state, callbacks)]); + case 'message': + if (state.messageLayout === 'wrap') { + return E( + 'td', + { + 'class': 'fwlive-message', + 'title': msgDisplay || '' + }, + E('div', { 'class': 'fwlive-message-wrap' }, [msgDisplay || '—']) + ); + } + return E( + 'td', + { + 'class': 'fwlive-message', + 'title': msgDisplay || '' + }, + [msgDisplay || '—'] + ); + default: + return E('td', {}, ['']); } } function renderThead(host, state, _callbacks) { const columns = state.columns || []; const tr = host.querySelector('thead tr'); - if (!tr) - return; + if (!tr) return; let colgroup = host.querySelector('colgroup'); @@ -185,8 +248,10 @@ function renderThead(host, state, _callbacks) { for (let i = 0; i < columns.length; i++) { const col = columns[i]; - colgroup.appendChild(E('col', { 'class': 'fwlive-col fwlive-col-' + col.replace(/_/g, '-') })); - tr.appendChild(E('th', { 'class': columnCellClass(col) }, [ columnLabel(col) ])); + colgroup.appendChild( + E('col', { 'class': 'fwlive-col fwlive-col-' + col.replace(/_/g, '-') }) + ); + tr.appendChild(E('th', { 'class': columnCellClass(col) }, [columnLabel(col)])); } } @@ -203,26 +268,34 @@ function renderRows(host, state, callbacks) { state.viewMode === 'simple' ? 'fwlive-row-clickable' : '', state.expandedRowId === r.id ? 'fwlive-row-expanded' : '', state.rowTint ? callbacks.actionRowTintClass(r.action) : '' - ].filter(Boolean).join(' '); + ] + .filter(Boolean) + .join(' '); const cells = []; for (let c = 0; c < columns.length; c++) cells.push(buildColumnCell(columns[c], r, state, callbacks)); - const tr = E('tr', { - 'class': rowClass, - 'click': state.viewMode === 'simple' - ? (ev) => callbacks.onRowClick(r.id, ev) : null - }, cells); + const tr = E( + 'tr', + { + 'class': rowClass, + 'click': state.viewMode === 'simple' ? (ev) => callbacks.onRowClick(r.id, ev) : null + }, + cells + ); host.appendChild(tr); if (state.viewMode === 'simple' && state.expandedRowId === r.id) { - host.appendChild(E('tr', { 'class': 'fwlive-msg-expand' }, [ - E('td', { 'colspan': String(columns.length) }, [ - E('div', { 'class': 'fwlive-msg-expand-label' }, [ _('Message') ]), - E('pre', { 'class': 'fwlive-msg-expand-body' }, - [ log.formatMessageDisplay(r.message, 'wrap') || '—' ]) + host.appendChild( + E('tr', { 'class': 'fwlive-msg-expand' }, [ + E('td', { 'colspan': String(columns.length) }, [ + E('div', { 'class': 'fwlive-msg-expand-label' }, [_('Message')]), + E('pre', { 'class': 'fwlive-msg-expand-body' }, [ + log.formatMessageDisplay(r.message, 'wrap') || '—' + ]) + ]) ]) - ])); + ); } } } diff --git a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/tint.js b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/tint.js index 375640b10..3b28c985c 100644 --- a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/tint.js +++ b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/tint.js @@ -1,4 +1,6 @@ 'use strict'; +/* SPDX-License-Identifier: Apache-2.0 */ +/* Copyright 2025-2026 Lucas Albers */ 'require baseclass'; /** @@ -21,8 +23,7 @@ var PASS_HEX = CLASSIC_PASS_HEX; var DENY_HEX = CLASSIC_DENY_HEX; function normalizeRowTint(mode) { - if (mode === 'off' || mode === 'accessible' || mode === 'classic') - return mode; + if (mode === 'off' || mode === 'accessible' || mode === 'classic') return mode; return 'classic'; } @@ -33,16 +34,13 @@ function hexPairForMode(mode) { } function parseCssRgbChannels(value) { - if (!value) - return null; + if (!value) return null; const s = String(value).trim().toLowerCase(); - if (s === 'transparent' || s === 'rgba(0, 0, 0, 0)' || s === 'rgba(0,0,0,0)') - return null; + if (s === 'transparent' || s === 'rgba(0, 0, 0, 0)' || s === 'rgba(0,0,0,0)') return null; const rgb = s.match(/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i); - if (rgb) - return [ parseFloat(rgb[1]), parseFloat(rgb[2]), parseFloat(rgb[3]) ]; + if (rgb) return [parseFloat(rgb[1]), parseFloat(rgb[2]), parseFloat(rgb[3])]; /* color-mix() often serializes as color(srgb r g b[/a]) with 0..1 channels. */ const modern = s.match(/color\(\s*srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/i); @@ -60,27 +58,22 @@ function cssColorPaintDelta(a, b) { const ca = parseCssRgbChannels(a); const cb = parseCssRgbChannels(b); /* Transparent vs opaque color is a real paint change (common off-state). */ - if (!ca && !cb) - return 0; - if (!ca && cb) - return Math.abs(cb[0]) + Math.abs(cb[1]) + Math.abs(cb[2]); - if (ca && !cb) - return Math.abs(ca[0]) + Math.abs(ca[1]) + Math.abs(ca[2]); + if (!ca && !cb) return 0; + if (!ca && cb) return Math.abs(cb[0]) + Math.abs(cb[1]) + Math.abs(cb[2]); + if (ca && !cb) return Math.abs(ca[0]) + Math.abs(ca[1]) + Math.abs(ca[2]); return Math.abs(ca[0] - cb[0]) + Math.abs(ca[1] - cb[1]) + Math.abs(ca[2] - cb[2]); } function tintShouldEngageFallback(opts) { const o = opts || {}; - const minDelta = (typeof o.minDelta === 'number') ? o.minDelta : PAINT_DELTA_MIN; + const minDelta = typeof o.minDelta === 'number' ? o.minDelta : PAINT_DELTA_MIN; /* Visible paint is the success criterion; token/CSS.supports are only used when paint cannot be measured (no delta sample yet). */ - if (typeof o.paintDelta === 'number') - return o.paintDelta < minDelta; + if (typeof o.paintDelta === 'number') return o.paintDelta < minDelta; - if (o.tokenResolved === false) - return true; + if (o.tokenResolved === false) return true; return false; } diff --git a/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js b/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js index 6aeefd25b..901c112a9 100644 --- a/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js +++ b/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js @@ -1,4 +1,6 @@ 'use strict'; +/* SPDX-License-Identifier: Apache-2.0 */ +/* Copyright 2025-2026 Lucas Albers */ /* * LuCI Firewall Live View — client-side view (view.extend + ubus fwlive.poll). * UI interaction patterns inspired by OPNsense Live View; original implementation @@ -21,7 +23,7 @@ const callFwlivePoll = rpc.declare({ object: 'fwlive', method: 'poll', - params: [ 'addresses' ] + params: ['addresses'] /* Full reply object kept so reply.error reaches fetchEntries (#233). */ }); @@ -33,23 +35,25 @@ const callFwliveRules = rpc.declare({ const callFwliveResolve = rpc.declare({ object: 'fwlive', method: 'resolve', - params: [ 'addresses' ], + params: ['addresses'], expect: { names: {} } }); const callFwliveLoggingStatus = rpc.declare({ object: 'fwlive', method: 'logging_status', - expect: { '': { - wan_zone: null, - wan_log: false, - wan_log_limit: null, - nf_log_ipv4: false, - nf_log_ipv6: false, - ready: false, - blockers: [], - warnings: [] - } } + expect: { + '': { + wan_zone: null, + wan_log: false, + wan_log_limit: null, + nf_log_ipv4: false, + nf_log_ipv6: false, + ready: false, + blockers: [], + warnings: [] + } + } }); const callFwliveEnableLogging = rpc.declare({ @@ -67,7 +71,7 @@ const callFwliveDisableLogging = rpc.declare({ function storedValue(key, fallback) { try { const v = localStorage.getItem(key); - return (v === null) ? fallback : v; + return v === null ? fallback : v; } catch (e) { return fallback; } @@ -84,7 +88,7 @@ function storeValue(key, value) { function optionNodes(pairs) { const opts = []; for (let i = 0; i < pairs.length; i++) - opts.push(E('option', { 'value': pairs[i][0] }, [ pairs[i][1] ])); + opts.push(E('option', { 'value': pairs[i][0] }, [pairs[i][1]])); return opts; } @@ -146,7 +150,7 @@ return view.extend({ readFilters() { const val = (id) => { const el = document.getElementById(id); - return el ? (el.value || '') : ''; + return el ? el.value || '' : ''; }; return { q: val('fwlive-q').trim(), @@ -166,20 +170,17 @@ return view.extend({ .map((k) => '%s=%s'.format(encodeURIComponent(k), encodeURIComponent(filters[k]))); if (this.rowLimit !== constants.DEFAULT_ROW_LIMIT) parts.push('limit=%s'.format(encodeURIComponent(this.rowLimit))); - if (this.viewMode === 'detailed') - parts.push('view=detailed'); + if (this.viewMode === 'detailed') parts.push('view=detailed'); location.hash = parts.join('&'); }, applyHash() { - if (!location.hash || location.hash.length < 2) - return; + if (!location.hash || location.hash.length < 2) return; const entries = location.hash.substring(1).split('&'); for (let i = 0; i < entries.length; i++) { const kv = entries[i].split('='); - if (kv.length !== 2) - continue; + if (kv.length !== 2) continue; const key = decodeURIComponent(kv[0]); const val = decodeURIComponent(kv[1]); if (key === 'limit') { @@ -191,10 +192,8 @@ return view.extend({ continue; } if (key === 'view') { - if (val === 'advanced' || val === 'detailed') - this.viewMode = 'detailed'; - else if (val === 'simple') - this.viewMode = 'simple'; + if (val === 'advanced' || val === 'detailed') this.viewMode = 'detailed'; + else if (val === 'simple') this.viewMode = 'simple'; continue; } const el = document.getElementById('fwlive-' + key); @@ -202,17 +201,14 @@ return view.extend({ proto.setProtoFilterValue(val); continue; } - if (el) - el.value = val; + if (el) el.value = val; } }, readViewMode() { const v = storedValue('fwlive-view-mode', null); - if (v === 'advanced' || v === 'detailed') - return 'detailed'; - if (v === 'simple') - return 'simple'; + if (v === 'advanced' || v === 'detailed') return 'detailed'; + if (v === 'simple') return 'simple'; return 'simple'; }, @@ -230,15 +226,11 @@ return view.extend({ readRowTint() { const v = storedValue('fwlive-row-tint', null); - if (v === null) - return constants.DEFAULT_ROW_TINT; + if (v === null) return constants.DEFAULT_ROW_TINT; /* Migrate pre-mode checkbox storage. */ - if (v === '1' || v === 'true') - return 'classic'; - if (v === '0' || v === 'false') - return 'off'; - if (constants.ROW_TINT_OPTIONS.indexOf(v) >= 0) - return v; + if (v === '1' || v === 'true') return 'classic'; + if (v === '0' || v === 'false') return 'off'; + if (constants.ROW_TINT_OPTIONS.indexOf(v) >= 0) return v; return constants.DEFAULT_ROW_TINT; }, @@ -248,8 +240,8 @@ return view.extend({ rowTintPaletteOptions() { return optionNodes([ - [ 'classic', _('Classic (green/red)') ], - [ 'accessible', _('Accessible (teal/orange)') ] + ['classic', _('Classic (green/red)')], + ['accessible', _('Accessible (teal/orange)')] ]); }, @@ -259,23 +251,20 @@ return view.extend({ applyRowTintMode() { const map = document.querySelector('.fwlive-map'); - if (!map) - return; + if (!map) return; map.setAttribute('data-row-tint', this.rowTint); - if (!this.rowTintEnabled() && this.tintFallbackActive) - this.clearTintFallback(map); + if (!this.rowTintEnabled() && this.tintFallbackActive) this.clearTintFallback(map); }, onRowTintEnabledChange(ev) { const on = !!(ev && ev.target && ev.target.checked); if (on) { - const pal = (this.rowTintPalette === 'accessible') ? 'accessible' : 'classic'; + const pal = this.rowTintPalette === 'accessible' ? 'accessible' : 'classic'; this.rowTintPalette = pal; this.rowTint = pal; } else { - if (this.rowTintEnabled()) - this.rowTintPalette = this.rowTint; + if (this.rowTintEnabled()) this.rowTintPalette = this.rowTint; this.rowTint = 'off'; } this.commitRowTintChange(); @@ -291,10 +280,9 @@ return view.extend({ onRowTintPaletteChange(ev) { const v = ev && ev.target ? ev.target.value : 'classic'; - const pal = (v === 'accessible') ? 'accessible' : 'classic'; + const pal = v === 'accessible' ? 'accessible' : 'classic'; this.rowTintPalette = pal; - if (!this.rowTintEnabled()) - return; + if (!this.rowTintEnabled()) return; this.rowTint = pal; this.commitRowTintChange(); }, @@ -302,36 +290,30 @@ return view.extend({ updateRowTintUi() { const on = this.rowTintEnabled(); const cb = document.getElementById('fwlive-row-tint-toggle'); - if (cb) - cb.checked = on; + if (cb) cb.checked = on; const wrap = document.getElementById('fwlive-row-tint-palette-wrap'); if (wrap) { - if (on) - wrap.classList.remove('fwlive-hidden'); - else - wrap.classList.add('fwlive-hidden'); + if (on) wrap.classList.remove('fwlive-hidden'); + else wrap.classList.add('fwlive-hidden'); } const tintSel = document.getElementById('fwlive-row-tint'); if (tintSel) { const pal = on ? this.rowTint : this.rowTintPalette; - tintSel.value = (pal === 'accessible') ? 'accessible' : 'classic'; + tintSel.value = pal === 'accessible' ? 'accessible' : 'classic'; } }, actionRowTintClass(action) { const a = (action || '').toLowerCase(); - if (a === 'pass') - return 'fwlive-row-pass'; - if (a === 'drop' || a === 'reject' || a === 'block') - return 'fwlive-row-deny'; + if (a === 'pass') return 'fwlive-row-pass'; + if (a === 'drop' || a === 'reject' || a === 'block') return 'fwlive-row-deny'; return ''; }, applyTintFallback(map) { - if (!map) - return; + if (!map) return; const pair = tint.hexPairForMode(this.rowTint); map.style.setProperty('--fwlive-pass-color', pair.pass); @@ -342,8 +324,7 @@ return view.extend({ }, clearTintFallback(map) { - if (!map) - return; + if (!map) return; map.style.removeProperty('--fwlive-pass-color'); map.style.removeProperty('--fwlive-deny-color'); @@ -354,8 +335,7 @@ return view.extend({ updateTintWarnUi() { const el = document.getElementById('fwlive-tint-warn'); - if (!el) - return; + if (!el) return; el.style.display = this.tintFallbackActive ? 'inline' : 'none'; }, @@ -363,19 +343,15 @@ return view.extend({ probeRowTintPaint() { const map = document.querySelector('.fwlive-map'); const body = document.querySelector('#fwlive-table tbody'); - if (!map || !body) - return; + if (!map || !body) return; /* Prefer a non-alt row — zebra --background-color-medium can look "tinted" when transparent. */ let tr = body.querySelector('tr:not(.fwlive-row-alt)'); - if (!tr) - tr = body.querySelector('tr'); - if (!tr) - return; + if (!tr) tr = body.querySelector('tr'); + if (!tr) return; const td = tr.querySelector('td'); - if (!td || typeof getComputedStyle !== 'function') - return; + if (!td || typeof getComputedStyle !== 'function') return; const hadPass = tr.classList.contains('fwlive-row-pass'); const hadDeny = tr.classList.contains('fwlive-row-deny'); @@ -387,10 +363,8 @@ return view.extend({ const onBg = getComputedStyle(td).backgroundColor; tr.classList.remove('fwlive-row-pass', 'fwlive-row-deny'); - if (hadPass) - tr.classList.add('fwlive-row-pass'); - if (hadDeny) - tr.classList.add('fwlive-row-deny'); + if (hadPass) tr.classList.add('fwlive-row-pass'); + if (hadDeny) tr.classList.add('fwlive-row-deny'); const passToken = getComputedStyle(map).getPropertyValue('--fwlive-pass-color').trim(); const paintDelta = tint.cssColorPaintDelta(onBg, offBg); @@ -404,17 +378,13 @@ return view.extend({ }); this.tintProbeDone = true; - if (broken) - this.applyTintFallback(map); - else if (this.tintFallbackActive) - this.clearTintFallback(map); - else - this.updateTintWarnUi(); + if (broken) this.applyTintFallback(map); + else if (this.tintFallbackActive) this.clearTintFallback(map); + else this.updateTintWarnUi(); }, isLikelyIp(addr) { - if (!addr) - return false; + if (!addr) return false; return /^[\da-fA-F:.]+$/.test(addr); }, @@ -424,8 +394,7 @@ return view.extend({ }, setViewMode(mode) { - if (constants.VIEW_MODES.indexOf(mode) < 0 || mode === this.viewMode) - return; + if (constants.VIEW_MODES.indexOf(mode) < 0 || mode === this.viewMode) return; this.viewMode = mode; this.expandedRowId = null; @@ -440,22 +409,18 @@ return view.extend({ const simpleBtn = document.getElementById('fwlive-view-simple'); const detailBtn = document.getElementById('fwlive-view-detail'); const detailed = this.viewMode === 'detailed'; - if (simpleBtn) - simpleBtn.setAttribute('aria-pressed', detailed ? 'false' : 'true'); - if (detailBtn) - detailBtn.setAttribute('aria-pressed', detailed ? 'true' : 'false'); + if (simpleBtn) simpleBtn.setAttribute('aria-pressed', detailed ? 'false' : 'true'); + if (detailBtn) detailBtn.setAttribute('aria-pressed', detailed ? 'true' : 'false'); const map = document.querySelector('.fwlive-map'); - if (map) - map.setAttribute('data-view', this.viewMode); + if (map) map.setAttribute('data-view', this.viewMode); this.updateFilterPanelUi(); }, updateFilterPanelUi() { const details = document.getElementById('fwlive-more-filters'); - if (!details) - return; + if (!details) return; if (this.viewMode === 'detailed') { details.open = true; @@ -463,18 +428,20 @@ return view.extend({ } const filters = this.readFilters(); - const hasExtra = !!(filters.interface || filters.src || filters.dst - || filters.sport || filters.dport); - if (hasExtra) - details.open = true; + const hasExtra = !!( + filters.interface || + filters.src || + filters.dst || + filters.sport || + filters.dport + ); + if (hasExtra) details.open = true; }, onRowClick(rowId, ev) { - if (this.viewMode !== 'simple') - return; + if (this.viewMode !== 'simple') return; - if (ev && ev.target && ev.target.closest - && ev.target.closest('a.fwlive-filter-link')) + if (ev && ev.target && ev.target.closest && ev.target.closest('a.fwlive-filter-link')) return; this.expandedRowId = this.expandedRowId === rowId ? null : rowId; @@ -483,8 +450,7 @@ return view.extend({ renderThead() { const el = document.getElementById('fwlive-table'); - if (!el) - return; + if (!el) return; table.renderThead(el, { columns: this.activeColumns().slice() }, {}); }, @@ -496,8 +462,7 @@ return view.extend({ this.firewallBackend = (res && res.backend) || 'nft'; /* Bounds / mktemp failures are reply.error — same idea as poll (#245). */ this.lastRulesError = (res && res.error) || null; - if (this.lastRulesError) - console.warn('fwlive rules map error:', this.lastRulesError); + if (this.lastRulesError) console.warn('fwlive rules map error:', this.lastRulesError); } catch (e) { this.rulesMap = {}; this.firewallBackend = 'nft'; @@ -507,17 +472,14 @@ return view.extend({ }, backendDisplayLabel() { - if (this.firewallBackend === 'iptables') - return _('using iptables'); - if (this.firewallBackend === 'nft') - return _('using fw4'); + if (this.firewallBackend === 'iptables') return _('using iptables'); + if (this.firewallBackend === 'nft') return _('using fw4'); return ''; }, updateBackendUi() { const map = document.querySelector('.fwlive-map'); - if (map) - map.setAttribute('data-backend', this.firewallBackend || 'unknown'); + if (map) map.setAttribute('data-backend', this.firewallBackend || 'unknown'); const label = document.getElementById('fwlive-backend'); if (label) { @@ -529,8 +491,7 @@ return view.extend({ err = _('Rule labels incomplete — map truncated'); else if (this.lastRulesError === 'mktemp_failed') err = _('Rule labels unavailable — temp file failed'); - else - err = _('Rule labels unavailable'); + else err = _('Rule labels unavailable'); text = text ? text + ' \u00b7 ' + err : err; degraded = true; } @@ -559,8 +520,7 @@ return view.extend({ }, async handleEnableLogging() { - if (this.loggingBusy) - return; + if (this.loggingBusy) return; this.loggingBusy = true; this.loggingNotice = ''; @@ -571,19 +531,23 @@ return view.extend({ const res = await callFwliveEnableLogging(); if (!res || !res.ok) { if (res && res.error === 'nf_log_missing') - this.loggingNotice = _('Cannot enable logging until kernel log modules are installed.'); + this.loggingNotice = _( + 'Cannot enable logging until kernel log modules are installed.' + ); else if (res && res.error === 'firewall_changes_pending') - this.loggingNotice = _('Another change is staged for the firewall; apply or revert it first.'); - else - this.loggingNotice = _('Could not enable logging.'); + this.loggingNotice = _( + 'Another change is staged for the firewall; apply or revert it first.' + ); + else this.loggingNotice = _('Could not enable logging.'); await this.loadLoggingStatus(); return; } if (res.changed) - this.loggingNotice = _('WAN drop/reject logging is on. Blocked inbound traffic should appear here as it happens — not normal LAN browsing.'); - else - this.loggingNotice = _('WAN logging is already enabled.'); + this.loggingNotice = _( + 'WAN drop/reject logging is on. Blocked inbound traffic should appear here as it happens — not normal LAN browsing.' + ); + else this.loggingNotice = _('WAN logging is already enabled.'); logging.persistConsentDismissed(); await this.loadLoggingStatus(); @@ -598,8 +562,7 @@ return view.extend({ }, async handleDisableLogging() { - if (this.loggingBusy) - return; + if (this.loggingBusy) return; this.loggingBusy = true; this.loggingNotice = ''; @@ -609,15 +572,15 @@ return view.extend({ const res = await callFwliveDisableLogging(); if (!res || !res.ok) { if (res && res.error === 'firewall_changes_pending') - this.loggingNotice = _('Another change is staged for the firewall; apply or revert it first.'); - else - this.loggingNotice = _('Could not disable logging.'); + this.loggingNotice = _( + 'Another change is staged for the firewall; apply or revert it first.' + ); + else this.loggingNotice = _('Could not disable logging.'); await this.loadLoggingStatus(); return; } - if (res.changed) - this.loggingNotice = _('WAN drop/reject logging is off.'); + if (res.changed) this.loggingNotice = _('WAN drop/reject logging is off.'); await this.loadLoggingStatus(); } catch (e) { this.loggingNotice = _('Administrator access is required to disable logging.'); @@ -631,21 +594,16 @@ return view.extend({ shouldShowLoggingConsent() { const st = this.loggingStatus; - if (!st || st.wan_log) - return false; + if (!st || st.wan_log) return false; const blockers = st.blockers || []; - if (blockers.length) - return false; - if (logging.consentDismissedPermanent()) - return false; - if (this.consentDismissedSession) - return false; + if (blockers.length) return false; + if (logging.consentDismissedPermanent()) return false; + if (this.consentDismissedSession) return false; return true; }, handleDismissConsent(persist) { - if (persist) - logging.persistConsentDismissed(); + if (persist) logging.persistConsentDismissed(); this.consentDismissedSession = true; this._loggingEmptySig = ''; this.updateEmptyStateUi(); @@ -666,9 +624,7 @@ return view.extend({ loggingUiSignature() { const st = this.loggingStatus; /* Sort blockers so unstable backend order does not force a rebuild. */ - const blockers = (st && st.blockers) - ? st.blockers.slice().sort().join(',') - : ''; + const blockers = st && st.blockers ? st.blockers.slice().sort().join(',') : ''; return [ st ? (st.wan_log ? '1' : '0') : 'x', st ? String(st.wan_log_limit || '') : '', @@ -681,12 +637,10 @@ return view.extend({ updateLoggingToolbarUi() { const bar = document.getElementById('fwlive-logging-bar'); - if (!bar) - return; + if (!bar) return; const sig = this.loggingUiSignature(); - if (sig === this._loggingToolbarSig) - return; + if (sig === this._loggingToolbarSig) return; logging.renderToolbar(bar, this.loggingState(), { onEnable: () => this.handleEnableLogging(), @@ -697,33 +651,27 @@ return view.extend({ updateEmptyStateUi() { const empty = document.getElementById('fwlive-empty'); - if (!empty) - return; + if (!empty) return; const sig = this.loggingUiSignature(); - if (sig === this._loggingEmptySig) - return; + if (sig === this._loggingEmptySig) return; const visible = empty.style.display !== 'none'; logging.renderEmptyState(empty, this.loggingState(), { onEnable: () => this.handleEnableLogging(), onDismissConsent: (persist) => this.handleDismissConsent(persist) }); - if (visible) - empty.style.display = 'block'; + if (visible) empty.style.display = 'block'; this._loggingEmptySig = sig; }, resolveRuleLabel(hint) { - if (!hint) - return ''; + if (!hint) return ''; - if (this.rulesMap[hint]) - return this.rulesMap[hint]; + if (this.rulesMap[hint]) return this.rulesMap[hint]; const slug = hint.toLowerCase(); - if (this.rulesMap[slug]) - return this.rulesMap[slug]; + if (this.rulesMap[slug]) return this.rulesMap[slug]; return log.formatRuleLabel(hint); }, @@ -739,15 +687,12 @@ return view.extend({ let pollNew = 0; for (let i = 0; i < raw.length; i++) { - if (!log.isFirewallEvent(raw[i])) - continue; + if (!log.isFirewallEvent(raw[i])) continue; const row = this.enrichEntry(log.normalizeEntry(raw[i])); - if (seen[row.id]) - continue; + if (seen[row.id]) continue; seen[row.id] = true; - if (this.rememberSessionId(row.id)) - pollNew++; + if (this.rememberSessionId(row.id)) pollNew++; normalized.push(row); } @@ -755,8 +700,7 @@ return view.extend({ }, async fetchEntries() { - if (!this.sessionSeen) - this.sessionSeen = new Set(); + if (!this.sessionSeen) this.sessionSeen = new Set(); let reply; try { @@ -767,7 +711,7 @@ return view.extend({ ? constants.FETCH_LINES_MAX : Math.min(Math.max(this.rowLimit * 4, 100), constants.FETCH_LINES_MAX); reply = await callFwlivePoll({ - addresses: [ String(fetchLines) ] + addresses: [String(fetchLines)] }); } catch (e) { this.lastPollError = true; @@ -803,10 +747,8 @@ return view.extend({ }, rememberSessionId(id) { - if (!this.sessionSeen) - this.sessionSeen = new Set(); - if (this.sessionSeen.has(id)) - return false; + if (!this.sessionSeen) this.sessionSeen = new Set(); + if (this.sessionSeen.has(id)) return false; this.sessionSeen.add(id); const cap = Math.max(this.rowLimit * 2, constants.FETCH_LINES_MAX); @@ -824,15 +766,12 @@ return view.extend({ statusSuffix() { const bits = []; if (this.paused) { - if (this.pauseBufferLoading) - bits.push(_('loading buffer')); + if (this.pauseBufferLoading) bits.push(_('loading buffer')); } const cap = this.ingestCap(); - if (this.entries.length >= cap && cap > 0) - bits.push(_('buffer full')); - if (this.floodSuppressed) - bits.push(_('render paused (high rate)')); + if (this.entries.length >= cap && cap > 0) bits.push(_('buffer full')); + if (this.floodSuppressed) bits.push(_('render paused (high rate)')); if (!this.paused && !this.followLive) bits.push(_('scroll frozen — scroll to top to follow live')); return bits.length ? ' — ' + bits.join(', ') : ''; @@ -840,14 +779,13 @@ return view.extend({ refillRenderBucket() { const now = Date.now(); - if (!this.renderBucketMs) - this.renderBucketMs = now; + if (!this.renderBucketMs) this.renderBucketMs = now; const elapsed = now - this.renderBucketMs; this.renderBucketMs = now; this.renderBucket = Math.min( constants.RENDER_CAP_PER_SEC, - this.renderBucket + (elapsed * constants.RENDER_CAP_PER_SEC / 1000) + this.renderBucket + (elapsed * constants.RENDER_CAP_PER_SEC) / 1000 ); }, @@ -873,31 +811,29 @@ return view.extend({ const count = rows ? rows.length : 0; const headId = count ? rows[0].id : ''; - if (!count && !this.lastRenderedRowCount) - return 0; + if (!count && !this.lastRenderedRowCount) return 0; - if (count === this.lastRenderedRowCount && headId === this.lastRenderedHeadId) - return 0; + if (count === this.lastRenderedRowCount && headId === this.lastRenderedHeadId) return 0; /* * Visible row-count changes (Limit up/down, trim) must not be skipped by * the flood throttle — otherwise status can show N/limit while the table * still paints the previous size under ping -A. */ - if (count !== this.lastRenderedRowCount) - return 1; + if (count !== this.lastRenderedRowCount) return 1; return Math.max(1, this.lastPollNewEvents || 1); }, updateFloodBanner() { const el = document.getElementById('fwlive-flood'); - if (!el) - return; + if (!el) return; if (this.floodSuppressed) { el.style.display = 'block'; - el.textContent = _('High event rate — table refresh is throttled to protect the browser. The buffer still updates; refresh will resume automatically.'); + el.textContent = _( + 'High event rate — table refresh is throttled to protect the browser. The buffer still updates; refresh will resume automatically.' + ); } else { el.style.display = 'none'; el.textContent = ''; @@ -924,22 +860,18 @@ return view.extend({ shown = this.entries.filter((row) => log.matchesFilter(row, filters)).length; } - if (this.pauseBufferLoading && stored === 0) - return _('loading…') + suffix; + if (this.pauseBufferLoading && stored === 0) return _('loading…') + suffix; - if (shown) - return _('%d matching · %d/%d stored').format(shown, stored, limit) + suffix; + if (shown) return _('%d matching · %d/%d stored').format(shown, stored, limit) + suffix; - if (stored) - return _('0 matching · %d/%d stored').format(stored, limit) + suffix; + if (stored) return _('0 matching · %d/%d stored').format(stored, limit) + suffix; return ''; }, updateStatus(rows) { const status = document.getElementById('fwlive-status'); - if (!status) - return; + if (!status) return; const matchCount = rows ? rows.length : this.filteredRows().length; const suffix = this.statusSuffix(); @@ -950,16 +882,13 @@ return view.extend({ return; } - status.className = this.paused - ? 'fwlive-status fwlive-status-paused' - : 'fwlive-status'; + status.className = this.paused ? 'fwlive-status fwlive-status-paused' : 'fwlive-status'; status.textContent = this.compactCountText(matchCount); }, readRowLimit() { const n = parseInt(storedValue('fwlive-row-limit', null), 10); - if (constants.ROW_LIMIT_OPTIONS.indexOf(n) >= 0) - return n; + if (constants.ROW_LIMIT_OPTIONS.indexOf(n) >= 0) return n; return constants.DEFAULT_ROW_LIMIT; }, @@ -968,10 +897,10 @@ return view.extend({ }, applyRowLimit(limit) { - const n = constants.ROW_LIMIT_OPTIONS.indexOf(limit) >= 0 ? limit : constants.DEFAULT_ROW_LIMIT; + const n = + constants.ROW_LIMIT_OPTIONS.indexOf(limit) >= 0 ? limit : constants.DEFAULT_ROW_LIMIT; this.rowLimit = n; - if (!this.paused && this.entries.length > n) - this.entries = this.entries.slice(-n); + if (!this.paused && this.entries.length > n) this.entries = this.entries.slice(-n); }, updateStreamControlsUi() { @@ -983,25 +912,17 @@ return view.extend({ const hostCb = document.getElementById('fwlive-show-hostnames'); if (map) { - if (this.paused) - map.classList.add('fwlive-watch-paused'); - else - map.classList.remove('fwlive-watch-paused'); + if (this.paused) map.classList.add('fwlive-watch-paused'); + else map.classList.remove('fwlive-watch-paused'); } if (dot) { - if (this.paused) - dot.classList.remove('fwlive-dot-on'); - else - dot.classList.add('fwlive-dot-on'); + if (this.paused) dot.classList.remove('fwlive-dot-on'); + else dot.classList.add('fwlive-dot-on'); } - if (label) - label.textContent = this.paused ? _('Paused') : _('Watching'); - if (pauseBtn) - pauseBtn.textContent = this.paused ? _('Resume') : _('Pause'); - if (sel) - sel.value = String(this.rowLimit); - if (hostCb) - hostCb.checked = !!this.showHostnames; + if (label) label.textContent = this.paused ? _('Paused') : _('Watching'); + if (pauseBtn) pauseBtn.textContent = this.paused ? _('Resume') : _('Pause'); + if (sel) sel.value = String(this.rowLimit); + if (hostCb) hostCb.checked = !!this.showHostnames; this.updateRowTintUi(); }, @@ -1012,10 +933,8 @@ return view.extend({ this.resolveGeneration = (this.resolveGeneration || 0) + 1; this.resolveInFlight = false; - if (this.showHostnames) - this.resolveHostnamesForEntries(this.filteredRows()); - else - this.renderRows(true); + if (this.showHostnames) this.resolveHostnamesForEntries(this.filteredRows()); + else this.renderRows(true); }, onPauseClick() { @@ -1027,11 +946,13 @@ return view.extend({ this.pauseBufferLoading = true; this.updateStatus(); this.fetchEntries() - .catch(function() {}) - .finally(function() { - this.pauseBufferLoading = false; - this.updateStatus(); - }.bind(this)); + .catch(function () {}) + .finally( + function () { + this.pauseBufferLoading = false; + this.updateStatus(); + }.bind(this) + ); return; } @@ -1041,16 +962,17 @@ return view.extend({ this.resumeMerge = true; this.fetchEntries() .then(() => this.renderRows(true)) - .finally(function() { - this.resumeMerge = false; - }.bind(this)); + .finally( + function () { + this.resumeMerge = false; + }.bind(this) + ); } }, onRowLimitChange(ev) { const n = parseInt(ev && ev.target ? ev.target.value : '', 10); - if (!isFinite(n) || constants.ROW_LIMIT_OPTIONS.indexOf(n) < 0) - return; + if (!isFinite(n) || constants.ROW_LIMIT_OPTIONS.indexOf(n) < 0) return; this.applyRowLimit(n); this.saveRowLimit(); @@ -1059,16 +981,12 @@ return view.extend({ this.renderBucket = constants.RENDER_CAP_PER_SEC; this.floodSuppressed = false; this.pendingForceRender = true; - if (!this.paused) - this.renderRows(true); - else - this.updateStatus(); + if (!this.paused) this.renderRows(true); + else this.updateStatus(); this.fetchEntries() .then(() => { - if (this.paused) - this.updateStatus(); - else - this.renderRows(true); + if (this.paused) this.updateStatus(); + else this.renderRows(true); }) .finally(() => { this.pendingForceRender = false; @@ -1079,7 +997,7 @@ return view.extend({ const pairs = []; for (let i = 0; i < constants.ROW_LIMIT_OPTIONS.length; i++) { const n = constants.ROW_LIMIT_OPTIONS[i]; - pairs.push([ String(n), String(n) ]); + pairs.push([String(n), String(n)]); } return optionNodes(pairs); }, @@ -1087,40 +1005,38 @@ return view.extend({ /* G Grouped: Common / Also seen / Exclude — curated PROTO values from logs. */ protoSelectOptions() { return [ - E('option', { 'value': '' }, [ _('Any protocol') ]), + E('option', { 'value': '' }, [_('Any protocol')]), E('optgroup', { 'label': _('Common') }, [ - E('option', { 'value': 'TCP' }, [ 'TCP' ]), - E('option', { 'value': 'UDP' }, [ 'UDP' ]), - E('option', { 'value': 'ICMP' }, [ 'ICMP' ]), - E('option', { 'value': 'ICMPV6' }, [ _('ICMPv6') ]) + E('option', { 'value': 'TCP' }, ['TCP']), + E('option', { 'value': 'UDP' }, ['UDP']), + E('option', { 'value': 'ICMP' }, ['ICMP']), + E('option', { 'value': 'ICMPV6' }, [_('ICMPv6')]) ]), E('optgroup', { 'label': _('Also seen') }, [ - E('option', { 'value': 'IGMP' }, [ 'IGMP' ]), - E('option', { 'value': 'GRE' }, [ 'GRE' ]), - E('option', { 'value': 'ESP' }, [ 'ESP' ]), - E('option', { 'value': 'AH' }, [ 'AH' ]), - E('option', { 'value': 'SCTP' }, [ 'SCTP' ]) + E('option', { 'value': 'IGMP' }, ['IGMP']), + E('option', { 'value': 'GRE' }, ['GRE']), + E('option', { 'value': 'ESP' }, ['ESP']), + E('option', { 'value': 'AH' }, ['AH']), + E('option', { 'value': 'SCTP' }, ['SCTP']) ]), E('optgroup', { 'label': _('Exclude') }, [ - E('option', { 'value': '!TCP' }, [ _('not TCP') ]), - E('option', { 'value': '!UDP' }, [ _('not UDP') ]), - E('option', { 'value': '!ICMP' }, [ _('not ICMP') ]), - E('option', { 'value': '!ICMPV6' }, [ _('not ICMPv6') ]), - E('option', { 'value': '!IGMP' }, [ _('not IGMP') ]), - E('option', { 'value': '!GRE' }, [ _('not GRE') ]), - E('option', { 'value': '!ESP' }, [ _('not ESP') ]), - E('option', { 'value': '!AH' }, [ _('not AH') ]), - E('option', { 'value': '!SCTP' }, [ _('not SCTP') ]) + E('option', { 'value': '!TCP' }, [_('not TCP')]), + E('option', { 'value': '!UDP' }, [_('not UDP')]), + E('option', { 'value': '!ICMP' }, [_('not ICMP')]), + E('option', { 'value': '!ICMPV6' }, [_('not ICMPv6')]), + E('option', { 'value': '!IGMP' }, [_('not IGMP')]), + E('option', { 'value': '!GRE' }, [_('not GRE')]), + E('option', { 'value': '!ESP' }, [_('not ESP')]), + E('option', { 'value': '!AH' }, [_('not AH')]), + E('option', { 'value': '!SCTP' }, [_('not SCTP')]) ]) ]; }, filterClick(field, value, ev) { - if (ev && ev.preventDefault) - ev.preventDefault(); + if (ev && ev.preventDefault) ev.preventDefault(); - if (!value) - return; + if (!value) return; this.setFilterFieldValue(field, value); this.onFilterInput(); @@ -1131,23 +1047,18 @@ return view.extend({ for (let i = 0; i < entries.length; i++) { const r = entries[i]; - if (r.src && this.isLikelyIp(r.src)) - ips.add(r.src); - if (r.dst && this.isLikelyIp(r.dst)) - ips.add(r.dst); + if (r.src && this.isLikelyIp(r.src)) ips.add(r.src); + if (r.dst && this.isLikelyIp(r.dst)) ips.add(r.dst); } return Array.from(ips); }, async resolveHostnamesForEntries(entries) { - if (!this.showHostnames || this.resolveInFlight) - return; + if (!this.showHostnames || this.resolveInFlight) return; - if (!this.hostnameCache) - this.hostnameCache = new Map(); - if (!this.hostnameFailed) - this.hostnameFailed = new Map(); + if (!this.hostnameCache) this.hostnameCache = new Map(); + if (!this.hostnameFailed) this.hostnameFailed = new Map(); const ips = this.collectIpsFromEntries(entries); const need = []; @@ -1155,15 +1066,12 @@ return view.extend({ for (let i = 0; i < ips.length && need.length < 32; i++) { const ip = ips[i]; - if (this.hostnameCache.has(ip)) - continue; - if (hostname.failIsHot(this.hostnameFailed, ip, now)) - continue; + if (this.hostnameCache.has(ip)) continue; + if (hostname.failIsHot(this.hostnameFailed, ip, now)) continue; need.push(ip); } - if (!need.length) - return; + if (!need.length) return; this.resolveGeneration = (this.resolveGeneration || 0) + 1; const gen = this.resolveGeneration; @@ -1171,8 +1079,7 @@ return view.extend({ try { const res = await callFwliveResolve({ addresses: need }); - if (gen !== this.resolveGeneration) - return; + if (gen !== this.resolveGeneration) return; /* rpc.declare expect: { names: {} } already unwraps — res IS the map (#243). */ const names = res || {}; @@ -1189,23 +1096,19 @@ return view.extend({ } } - if (updated) - this.renderRows(true); + if (updated) this.renderRows(true); } catch (e) { /* resolve unavailable — show IPs */ } finally { - if (gen === this.resolveGeneration) - this.resolveInFlight = false; + if (gen === this.resolveGeneration) this.resolveInFlight = false; } }, setFilterFieldValue(field, value) { - if (field === 'proto') - return proto.setProtoFilterValue(value); + if (field === 'proto') return proto.setProtoFilterValue(value); const el = document.getElementById('fwlive-' + field); - if (!el) - return false; + if (!el) return false; /* SELECT: ensure uncommon click-to-filter / hash values remain selectable. */ if (el.tagName === 'SELECT' && value) { @@ -1216,20 +1119,17 @@ return view.extend({ break; } } - if (!found) - el.appendChild(E('option', { 'value': value }, [ value ])); + if (!found) el.appendChild(E('option', { 'value': value }, [value])); } el.value = value; - if (el.tagName === 'SELECT') - el.dispatchEvent(new Event('change', { bubbles: true })); + if (el.tagName === 'SELECT') el.dispatchEvent(new Event('change', { bubbles: true })); return true; }, clearFilter(field, ev) { - if (ev && ev.preventDefault) - ev.preventDefault(); + if (ev && ev.preventDefault) ev.preventDefault(); this.setFilterFieldValue(field, ''); this.onFilterInput(); @@ -1243,50 +1143,48 @@ return view.extend({ if (field === 'proto') { const cur = proto.readProtoFilter(); - if (!cur) - return; + if (!cur) return; proto.setProtoFilterValue(log.toggleFilterNegation(cur)); this.onFilterInput(); return; } const el = document.getElementById('fwlive-' + field); - if (!el || !el.value) - return; + if (!el || !el.value) return; this.setFilterFieldValue(field, log.toggleFilterNegation(el.value)); this.onFilterInput(); }, clearAllFilters(ev) { - if (ev && ev.preventDefault) - ev.preventDefault(); + if (ev && ev.preventDefault) ev.preventDefault(); for (let i = 0; i < this.FILTER_CHIP_FIELDS.length; i++) { const el = document.getElementById('fwlive-' + this.FILTER_CHIP_FIELDS[i].key); - if (el) - el.value = ''; + if (el) el.value = ''; } const protoCustom = document.getElementById('fwlive-proto-custom'); - if (protoCustom) - protoCustom.value = ''; + if (protoCustom) protoCustom.value = ''; this.onFilterInput(); }, renderFilterChips() { const bar = document.getElementById('fwlive-chips'); - if (!bar) - return; + if (!bar) return; - chips.renderFilterChips(bar, { - filters: Object.assign({}, this.readFilters()), - chipFields: this.FILTER_CHIP_FIELDS - }, { - onInvert: (field, ev) => this.invertFilter(field, ev), - onClear: (field, ev) => this.clearFilter(field, ev), - onClearAll: (ev) => this.clearAllFilters(ev) - }); + chips.renderFilterChips( + bar, + { + filters: Object.assign({}, this.readFilters()), + chipFields: this.FILTER_CHIP_FIELDS + }, + { + onInvert: (field, ev) => this.invertFilter(field, ev), + onClear: (field, ev) => this.clearFilter(field, ev), + onClearAll: (ev) => this.clearAllFilters(ev) + } + ); }, readMessageLayout() { @@ -1306,30 +1204,24 @@ return view.extend({ scroll.classList.toggle('fwlive-msg-oneline', oneline); scroll.classList.toggle('fwlive-msg-wrap', !oneline); } - if (wrapBtn) - wrapBtn.setAttribute('aria-pressed', oneline ? 'false' : 'true'); - if (onelineBtn) - onelineBtn.setAttribute('aria-pressed', oneline ? 'true' : 'false'); + if (wrapBtn) wrapBtn.setAttribute('aria-pressed', oneline ? 'false' : 'true'); + if (onelineBtn) onelineBtn.setAttribute('aria-pressed', oneline ? 'true' : 'false'); }, setMessageLayout(layout) { const next = layout === 'oneline' ? 'oneline' : 'wrap'; - if (next === this.messageLayout) - return; + if (next === this.messageLayout) return; this.messageLayout = next; this.saveMessageLayout(); this.updateMessageLayoutUi(); - if (this.paused) - this.updateStatus(); - else - this.renderRows(true); + if (this.paused) this.updateStatus(); + else this.renderRows(true); }, renderRows(force) { const el = document.getElementById('fwlive-table'); - if (!el) - return; + if (!el) return; const body = el.querySelector('tbody'); const empty = document.getElementById('fwlive-empty'); @@ -1357,32 +1249,33 @@ return view.extend({ const prevScroll = scroll ? scroll.scrollTop : 0; - if (empty) - empty.style.display = rows.length ? 'none' : 'block'; + if (empty) empty.style.display = rows.length ? 'none' : 'block'; this.updateStatus(rows); this.renderFilterChips(); - table.renderRows(body, { - rows: rows.slice(), - columns: this.activeColumns().slice(), - viewMode: this.viewMode, - messageLayout: this.messageLayout, - expandedRowId: this.expandedRowId, - rowTint: this.rowTintEnabled(), - showHostnames: !!this.showHostnames, - hostnameCache: this.hostnameCache, - firewallBackend: this.firewallBackend - }, { - onRowClick: (rowId, ev) => this.onRowClick(rowId, ev), - onFilterClick: (field, value, ev) => this.filterClick(field, value, ev), - actionRowTintClass: (action) => this.actionRowTintClass(action) - }); + table.renderRows( + body, + { + rows: rows.slice(), + columns: this.activeColumns().slice(), + viewMode: this.viewMode, + messageLayout: this.messageLayout, + expandedRowId: this.expandedRowId, + rowTint: this.rowTintEnabled(), + showHostnames: !!this.showHostnames, + hostnameCache: this.hostnameCache, + firewallBackend: this.firewallBackend + }, + { + onRowClick: (rowId, ev) => this.onRowClick(rowId, ev), + onFilterClick: (field, value, ev) => this.filterClick(field, value, ev), + actionRowTintClass: (action) => this.actionRowTintClass(action) + } + ); if (scroll) { - if (!this.paused && this.followLive) - scroll.scrollTop = 0; - else - scroll.scrollTop = prevScroll; + if (!this.paused && this.followLive) scroll.scrollTop = 0; + else scroll.scrollTop = prevScroll; } this.lastRenderedRowCount = rows.length; @@ -1390,13 +1283,11 @@ return view.extend({ if (rows.length && this.rowTintEnabled() && !this.tintProbeDone) { const runProbe = () => { - if (!this.tintProbeDone) - this.probeRowTintPaint(); + if (!this.tintProbeDone) this.probeRowTintPaint(); }; if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => requestAnimationFrame(runProbe)); - else - setTimeout(runProbe, 0); + else setTimeout(runProbe, 0); } else if (!this.rowTintEnabled() && this.tintFallbackActive) { this.clearTintFallback(document.querySelector('.fwlive-map')); this.tintProbeDone = false; @@ -1408,18 +1299,19 @@ return view.extend({ }, onFilterInputDebounced() { - if (this.filterInputTimer) - clearTimeout(this.filterInputTimer); - this.filterInputTimer = setTimeout(function() { - this.filterInputTimer = null; - this.onFilterInput(); - }.bind(this), 100); + if (this.filterInputTimer) clearTimeout(this.filterInputTimer); + this.filterInputTimer = setTimeout( + function () { + this.filterInputTimer = null; + this.onFilterInput(); + }.bind(this), + 100 + ); }, onScrollArea(ev) { const scroll = ev && ev.target; - if (!scroll || this.paused) - return; + if (!scroll || this.paused) return; this.followLive = scroll.scrollTop < 8; this.updateStatus(); @@ -1427,62 +1319,57 @@ return view.extend({ attachHandlers() { const scroll = document.getElementById('fwlive-scroll'); - if (scroll) - scroll.addEventListener('scroll', this.onScrollArea.bind(this)); + if (scroll) scroll.addEventListener('scroll', this.onScrollArea.bind(this)); - const ids = [ 'q', 'action', 'interface', 'src', 'dst', 'sport', 'dport' ]; + const ids = ['q', 'action', 'interface', 'src', 'dst', 'sport', 'dport']; for (let i = 0; i < ids.length; i++) { const el = document.getElementById('fwlive-' + ids[i]); - if (!el) - continue; + if (!el) continue; /* Text inputs: debounce rebuilds. Selects: apply immediately. */ if (el.tagName === 'SELECT') el.addEventListener('change', this.onFilterInput.bind(this)); - else - el.addEventListener('input', this.onFilterInputDebounced.bind(this)); + else el.addEventListener('input', this.onFilterInputDebounced.bind(this)); } const protoSel = document.getElementById('fwlive-proto'); const protoCustom = document.getElementById('fwlive-proto-custom'); if (protoSel) { - protoSel.addEventListener('change', function() { - if (protoCustom) - protoCustom.value = ''; - this.onFilterInput(); - }.bind(this)); + protoSel.addEventListener( + 'change', + function () { + if (protoCustom) protoCustom.value = ''; + this.onFilterInput(); + }.bind(this) + ); } if (protoCustom) { - protoCustom.addEventListener('input', function() { - if (protoCustom.value.trim() && protoSel) - protoSel.value = ''; - this.onFilterInputDebounced(); - }.bind(this)); + protoCustom.addEventListener( + 'input', + function () { + if (protoCustom.value.trim() && protoSel) protoSel.value = ''; + this.onFilterInputDebounced(); + }.bind(this) + ); } const pauseBtn = document.getElementById('fwlive-pause'); - if (pauseBtn) - pauseBtn.addEventListener('click', this.onPauseClick.bind(this)); + if (pauseBtn) pauseBtn.addEventListener('click', this.onPauseClick.bind(this)); const limitSel = document.getElementById('fwlive-limit'); - if (limitSel) - limitSel.addEventListener('change', this.onRowLimitChange.bind(this)); + if (limitSel) limitSel.addEventListener('change', this.onRowLimitChange.bind(this)); const hostCb = document.getElementById('fwlive-show-hostnames'); - if (hostCb) - hostCb.addEventListener('change', this.onShowHostnamesChange.bind(this)); + if (hostCb) hostCb.addEventListener('change', this.onShowHostnamesChange.bind(this)); const tintCb = document.getElementById('fwlive-row-tint-toggle'); - if (tintCb) - tintCb.addEventListener('change', this.onRowTintEnabledChange.bind(this)); + if (tintCb) tintCb.addEventListener('change', this.onRowTintEnabledChange.bind(this)); const tintSel = document.getElementById('fwlive-row-tint'); - if (tintSel) - tintSel.addEventListener('change', this.onRowTintPaletteChange.bind(this)); + if (tintSel) tintSel.addEventListener('change', this.onRowTintPaletteChange.bind(this)); }, async pollData() { - if (this.pollDataInFlight) - return; + if (this.pollDataInFlight) return; this.pollDataInFlight = true; try { @@ -1492,10 +1379,8 @@ return view.extend({ this.lastPollError = true; } - if (this.paused) - this.updateStatus(); - else - this.renderRows(!!this.pendingForceRender); + if (this.paused) this.updateStatus(); + else this.renderRows(!!this.pendingForceRender); try { await this.resolveHostnamesForEntries(this.filteredRows()); @@ -1513,222 +1398,377 @@ return view.extend({ poll.add(this.pollFn, 1); /* Best-effort teardown when leaving the page (LuCI SPA may full-reload). */ if (typeof window !== 'undefined' && window.addEventListener) { - window.addEventListener('pagehide', function() { - if (this.pollFn) { - try { poll.remove(this.pollFn); } catch (e) { /* poll gone */ } - this.pollFn = null; - } - if (this.filterInputTimer) { - clearTimeout(this.filterInputTimer); - this.filterInputTimer = null; - } - }.bind(this)); + window.addEventListener( + 'pagehide', + function () { + if (this.pollFn) { + try { + poll.remove(this.pollFn); + } catch (e) { + /* poll gone */ + } + this.pollFn = null; + } + if (this.filterInputTimer) { + clearTimeout(this.filterInputTimer); + this.filterInputTimer = null; + } + }.bind(this) + ); } } - return Promise.all([ - this.loadRulesMap(), - this.loadLoggingStatus() - ]).then(() => this.fetchEntries()); + return Promise.all([this.loadRulesMap(), this.loadLoggingStatus()]).then(() => + this.fetchEntries() + ); }, render() { - return E('div', { 'class': 'cbi-map fwlive-map', 'data-view': 'simple', 'data-row-tint': 'classic' }, [ - E('style', {}, [css.styleText]), - E('div', { 'id': 'fwlive-title-row', 'class': 'fwlive-title-row' }, [ - E('h2', {}, [ - _('Firewall Live View'), - E('span', { 'id': 'fwlive-backend', 'class': 'fwlive-backend' }, [ '' ]) - ]), - E('div', { 'class': 'fwlive-title-status' }, [ - E('span', { 'id': 'fwlive-watch-dot', 'class': 'fwlive-dot fwlive-dot-on', 'aria-hidden': 'true' }, [ '' ]), - E('span', { 'id': 'fwlive-watch-label', 'class': 'fwlive-watch-label' }, [ _('Watching') ]), - E('span', { 'id': 'fwlive-status', 'class': 'fwlive-status' }, [ '' ]), - E('span', { - 'id': 'fwlive-tint-warn', - 'class': 'fwlive-tint-warn', - 'title': _('Row tint used a local color fallback because the active LuCI theme did not apply pass/deny backgrounds.') - }, [ _('Theme tint fallback') ]) - ]) - ]), - E('div', { 'id': 'fwlive-watch-strip', 'class': 'fwlive-watch-strip' }, [ - E('div', { 'class': 'fwlive-watch-group' }, [ - E('button', { - 'id': 'fwlive-pause', - 'class': 'cbi-button fwlive-btn-ghost', - 'type': 'button' - }, [ _('Pause') ]), - E('span', { 'id': 'fwlive-logging-bar', 'class': 'fwlive-logging-bar' }, []) - ]), - E('div', { 'class': 'fwlive-watch-group' }, [ - E('span', { 'class': 'fwlive-watch-group-label' }, [ _('View') ]), - E('div', { - 'class': 'fwlive-watch-seg', - 'role': 'group', - 'aria-label': _('View') - }, [ - E('button', { - 'id': 'fwlive-view-simple', - 'class': 'cbi-button fwlive-seg-btn', - 'type': 'button', - 'aria-pressed': 'true', - 'click': () => this.setViewMode('simple') - }, [ _('Simple') ]), - E('button', { - 'id': 'fwlive-view-detail', - 'class': 'cbi-button fwlive-seg-btn', - 'type': 'button', - 'aria-pressed': 'false', - 'click': () => this.setViewMode('detailed') - }, [ _('Detail') ]) - ]) - ]), - E('div', { - 'id': 'fwlive-msg-group', - 'class': 'fwlive-watch-group' - }, [ - E('span', { 'class': 'fwlive-watch-group-label' }, [ _('Message') ]), - E('div', { - 'id': 'fwlive-msg-seg', - 'class': 'fwlive-watch-seg', - 'role': 'group', - 'aria-label': _('Message') - }, [ - E('button', { - 'id': 'fwlive-msg-wrap', - 'class': 'cbi-button fwlive-seg-btn', - 'type': 'button', - 'aria-pressed': 'true', - 'click': () => this.setMessageLayout('wrap') - }, [ _('Wrap') ]), - E('button', { - 'id': 'fwlive-msg-oneline', - 'class': 'cbi-button fwlive-seg-btn', - 'type': 'button', - 'aria-pressed': 'false', - 'click': () => this.setMessageLayout('oneline') - }, [ _('One line') ]) - ]) - ]) - ]), - E('div', { 'id': 'fwlive-flood', 'class': 'fwlive-flood' }, [ '' ]), - E('div', { 'id': 'fwlive-display-drawer', 'class': 'fwlive-display-bar' }, [ - E('span', { 'class': 'fwlive-display-bar-label' }, [ _('Display options') ]), - E('div', { 'class': 'fwlive-display-controls' }, [ - E('label', { 'class': 'fwlive-display-ctl', 'for': 'fwlive-limit' }, [ - _('Limit'), - E('select', { - 'id': 'fwlive-limit', - 'class': 'cbi-input-select' - }, this.limitSelectOptions()) + return E( + 'div', + { 'class': 'cbi-map fwlive-map', 'data-view': 'simple', 'data-row-tint': 'classic' }, + [ + E('style', {}, [css.styleText]), + E('div', { 'id': 'fwlive-title-row', 'class': 'fwlive-title-row' }, [ + E('h2', {}, [ + _('Firewall Live View'), + E('span', { 'id': 'fwlive-backend', 'class': 'fwlive-backend' }, ['']) ]), - E('label', { 'class': 'fwlive-display-ctl' }, [ - E('input', { - 'id': 'fwlive-row-tint-toggle', - 'type': 'checkbox', - 'title': _('Show pass/deny row background colors') - }), - _('Row tint') - ]), - E('label', { - 'id': 'fwlive-row-tint-palette-wrap', - 'class': 'fwlive-display-ctl', - 'for': 'fwlive-row-tint' - }, [ - _('Palette'), - E('select', { - 'id': 'fwlive-row-tint', - 'class': 'cbi-input-select', - 'title': _('Classic uses green/red; Accessible uses teal/orange') - }, this.rowTintPaletteOptions()) - ]), - E('label', { 'class': 'fwlive-display-ctl' }, [ - E('input', { - 'id': 'fwlive-show-hostnames', - 'type': 'checkbox' - }), - _('Show hostnames') - ]) - ]) - ]), - E('div', { 'id': 'fwlive-filter-panel', 'class': 'fwlive-filter-panel fwlive-find-row' }, [ - E('div', { 'class': 'fwlive-grid fwlive-grid-core' }, [ - E('input', { 'id': 'fwlive-q', 'class': 'cbi-input-text', 'placeholder': _('Quick search') }), - E('select', { 'id': 'fwlive-action', 'class': 'cbi-input-select' }, [ - E('option', { 'value': '' }, [ _('Any action') ]), - E('option', { 'value': 'pass' }, [ 'pass' ]), - E('option', { 'value': 'block' }, [ 'block' ]), - E('option', { 'value': 'drop' }, [ 'drop' ]), - E('option', { 'value': 'reject' }, [ 'reject' ]), - E('option', { 'value': 'unknown' }, [ 'unknown' ]), - E('option', { 'value': '!pass' }, [ _('not pass') ]), - E('option', { 'value': '!drop' }, [ _('not drop') ]), - E('option', { 'value': '!block' }, [ _('not block') ]), - E('option', { 'value': '!reject' }, [ _('not reject') ]), - E('option', { 'value': '!unknown' }, [ _('not unknown') ]) - ]), - E('div', { 'class': 'fwlive-proto-pair' }, [ - E('select', { - 'id': 'fwlive-proto', - 'class': 'cbi-input-select', - 'title': _('Protocol — common values') - }, this.protoSelectOptions()), - E('input', { - 'id': 'fwlive-proto-custom', - 'class': 'cbi-input-text', - 'placeholder': _('or type…'), - 'title': _('Custom protocol (prefix ! to exclude). Overrides the menu when set.'), - 'autocomplete': 'off' - }) + E('div', { 'class': 'fwlive-title-status' }, [ + E( + 'span', + { + 'id': 'fwlive-watch-dot', + 'class': 'fwlive-dot fwlive-dot-on', + 'aria-hidden': 'true' + }, + [''] + ), + E('span', { 'id': 'fwlive-watch-label', 'class': 'fwlive-watch-label' }, [ + _('Watching') + ]), + E('span', { 'id': 'fwlive-status', 'class': 'fwlive-status' }, ['']), + E( + 'span', + { + 'id': 'fwlive-tint-warn', + 'class': 'fwlive-tint-warn', + 'title': _( + 'Row tint used a local color fallback because the active LuCI theme did not apply pass/deny backgrounds.' + ) + }, + [_('Theme tint fallback')] + ) ]) ]), - E('details', { 'id': 'fwlive-more-filters', 'class': 'fwlive-more-filters' }, [ - E('summary', {}, [ _('More filters') ]), - E('div', { 'class': 'fwlive-grid fwlive-grid-extra' }, [ - E('input', { 'id': 'fwlive-interface', 'class': 'cbi-input-text', 'placeholder': _('Interface (prefix ! to exclude)') }), - E('input', { 'id': 'fwlive-src', 'class': 'cbi-input-text', 'placeholder': _('Source IP contains (! to exclude)') }), - E('input', { 'id': 'fwlive-sport', 'class': 'cbi-input-text', 'placeholder': _('Source port (! to exclude)') }), - E('input', { 'id': 'fwlive-dst', 'class': 'cbi-input-text', 'placeholder': _('Destination IP contains (! to exclude)') }), - E('input', { 'id': 'fwlive-dport', 'class': 'cbi-input-text', 'placeholder': _('Destination port (! to exclude)') }) - ]) - ]) - ]), - E('div', { 'id': 'fwlive-chips', 'class': 'fwlive-chips' }, []), - E('p', { 'class': 'fwlive-hint-line' }, - [ _('Click a cell to filter · ≠ on a chip to exclude · Ctrl+click a rule for firewall settings · in Simple view, click a row for the full message') ]), - E('div', { - 'id': 'fwlive-empty', - 'class': 'fwlive-empty', - 'style': 'display:none' - }, []), - E('div', { 'id': 'fwlive-scroll', 'class': 'fwlive-scroll fwlive-msg-wrap' }, [ - E('table', { 'id': 'fwlive-table', 'class': 'table cbi-section-table' }, [ - E('thead', {}, E('tr', {}, [])), - E('tbody', {}, []) - ]) - ]), - E('div', { 'class': 'fwlive-help-row' }, [ - E('details', { 'id': 'fwlive-help', 'class': 'fwlive-help' }, [ - E('summary', {}, [ _('Help') ]), - E('ul', {}, [ - E('li', {}, [ _('The table updates automatically when your firewall logs traffic. Use Pause if it moves too fast.') ]), - E('li', {}, [ _('Enable logging turns on WAN zone drop/reject logging only (same as Network → Firewall). It does not add rules or log normal LAN browsing.') ]), - E('li', {}, [ _('Display options on the bar set Limit, row tint, palette, and hostnames.') ]), - E('li', {}, [ _('The rate shown for WAN logging is the firewall zone log_limit. OpenWrt defaults to 10/minute when no explicit limit is configured; fwlive does not impose this cap.') ]), - E('li', { 'id': 'fwlive-manual-test' }, []), - E('li', {}, [ _('Click a row (Time or other non-link cells) to see the full log line (Simple view).') ]), - E('li', {}, [ _('Click an IP, action, or protocol to filter; use the Protocol menu (or ≠ on a chip) to exclude.') ]), - E('li', {}, [ _('Row tint shows pass/deny row backgrounds when checked. Choose Classic (green/red, default) or Accessible (teal/orange). Action text stays colored either way.') ]), - E('li', {}, [ _('Use Detail for all columns (flags, length, raw message).') ]), - E('li', {}, [ _('If Row tint looks missing, the active LuCI theme may omit success/error or info/warn CSS variables; fwlive falls back to local colors (air-gapped, no data leaves the device).') ]) + E('div', { 'id': 'fwlive-watch-strip', 'class': 'fwlive-watch-strip' }, [ + E('div', { 'class': 'fwlive-watch-group' }, [ + E( + 'button', + { + 'id': 'fwlive-pause', + 'class': 'cbi-button fwlive-btn-ghost', + 'type': 'button' + }, + [_('Pause')] + ), + E('span', { 'id': 'fwlive-logging-bar', 'class': 'fwlive-logging-bar' }, []) + ]), + E('div', { 'class': 'fwlive-watch-group' }, [ + E('span', { 'class': 'fwlive-watch-group-label' }, [_('View')]), + E( + 'div', + { + 'class': 'fwlive-watch-seg', + 'role': 'group', + 'aria-label': _('View') + }, + [ + E( + 'button', + { + 'id': 'fwlive-view-simple', + 'class': 'cbi-button fwlive-seg-btn', + 'type': 'button', + 'aria-pressed': 'true', + 'click': () => this.setViewMode('simple') + }, + [_('Simple')] + ), + E( + 'button', + { + 'id': 'fwlive-view-detail', + 'class': 'cbi-button fwlive-seg-btn', + 'type': 'button', + 'aria-pressed': 'false', + 'click': () => this.setViewMode('detailed') + }, + [_('Detail')] + ) + ] + ) + ]), + E( + 'div', + { + 'id': 'fwlive-msg-group', + 'class': 'fwlive-watch-group' + }, + [ + E('span', { 'class': 'fwlive-watch-group-label' }, [_('Message')]), + E( + 'div', + { + 'id': 'fwlive-msg-seg', + 'class': 'fwlive-watch-seg', + 'role': 'group', + 'aria-label': _('Message') + }, + [ + E( + 'button', + { + 'id': 'fwlive-msg-wrap', + 'class': 'cbi-button fwlive-seg-btn', + 'type': 'button', + 'aria-pressed': 'true', + 'click': () => this.setMessageLayout('wrap') + }, + [_('Wrap')] + ), + E( + 'button', + { + 'id': 'fwlive-msg-oneline', + 'class': 'cbi-button fwlive-seg-btn', + 'type': 'button', + 'aria-pressed': 'false', + 'click': () => this.setMessageLayout('oneline') + }, + [_('One line')] + ) + ] + ) + ] + ) + ]), + E('div', { 'id': 'fwlive-flood', 'class': 'fwlive-flood' }, ['']), + E('div', { 'id': 'fwlive-display-drawer', 'class': 'fwlive-display-bar' }, [ + E('span', { 'class': 'fwlive-display-bar-label' }, [_('Display options')]), + E('div', { 'class': 'fwlive-display-controls' }, [ + E('label', { 'class': 'fwlive-display-ctl', 'for': 'fwlive-limit' }, [ + _('Limit'), + E( + 'select', + { + 'id': 'fwlive-limit', + 'class': 'cbi-input-select' + }, + this.limitSelectOptions() + ) + ]), + E('label', { 'class': 'fwlive-display-ctl' }, [ + E('input', { + 'id': 'fwlive-row-tint-toggle', + 'type': 'checkbox', + 'title': _('Show pass/deny row background colors') + }), + _('Row tint') + ]), + E( + 'label', + { + 'id': 'fwlive-row-tint-palette-wrap', + 'class': 'fwlive-display-ctl', + 'for': 'fwlive-row-tint' + }, + [ + _('Palette'), + E( + 'select', + { + 'id': 'fwlive-row-tint', + 'class': 'cbi-input-select', + 'title': _( + 'Classic uses green/red; Accessible uses teal/orange' + ) + }, + this.rowTintPaletteOptions() + ) + ] + ), + E('label', { 'class': 'fwlive-display-ctl' }, [ + E('input', { + 'id': 'fwlive-show-hostnames', + 'type': 'checkbox' + }), + _('Show hostnames') + ]) ]) ]), - E('span', { - 'id': 'fwlive-build', - 'class': 'fwlive-build', - 'title': 'luci-app-fwlive' - }, [ 'v' + constants.APP_VERSION ]) - ]) - ]); + E( + 'div', + { 'id': 'fwlive-filter-panel', 'class': 'fwlive-filter-panel fwlive-find-row' }, + [ + E('div', { 'class': 'fwlive-grid fwlive-grid-core' }, [ + E('input', { + 'id': 'fwlive-q', + 'class': 'cbi-input-text', + 'placeholder': _('Quick search') + }), + E('select', { 'id': 'fwlive-action', 'class': 'cbi-input-select' }, [ + E('option', { 'value': '' }, [_('Any action')]), + E('option', { 'value': 'pass' }, ['pass']), + E('option', { 'value': 'block' }, ['block']), + E('option', { 'value': 'drop' }, ['drop']), + E('option', { 'value': 'reject' }, ['reject']), + E('option', { 'value': 'unknown' }, ['unknown']), + E('option', { 'value': '!pass' }, [_('not pass')]), + E('option', { 'value': '!drop' }, [_('not drop')]), + E('option', { 'value': '!block' }, [_('not block')]), + E('option', { 'value': '!reject' }, [_('not reject')]), + E('option', { 'value': '!unknown' }, [_('not unknown')]) + ]), + E('div', { 'class': 'fwlive-proto-pair' }, [ + E( + 'select', + { + 'id': 'fwlive-proto', + 'class': 'cbi-input-select', + 'title': _('Protocol — common values') + }, + this.protoSelectOptions() + ), + E('input', { + 'id': 'fwlive-proto-custom', + 'class': 'cbi-input-text', + 'placeholder': _('or type…'), + 'title': _( + 'Custom protocol (prefix ! to exclude). Overrides the menu when set.' + ), + 'autocomplete': 'off' + }) + ]) + ]), + E( + 'details', + { 'id': 'fwlive-more-filters', 'class': 'fwlive-more-filters' }, + [ + E('summary', {}, [_('More filters')]), + E('div', { 'class': 'fwlive-grid fwlive-grid-extra' }, [ + E('input', { + 'id': 'fwlive-interface', + 'class': 'cbi-input-text', + 'placeholder': _('Interface (prefix ! to exclude)') + }), + E('input', { + 'id': 'fwlive-src', + 'class': 'cbi-input-text', + 'placeholder': _('Source IP contains (! to exclude)') + }), + E('input', { + 'id': 'fwlive-sport', + 'class': 'cbi-input-text', + 'placeholder': _('Source port (! to exclude)') + }), + E('input', { + 'id': 'fwlive-dst', + 'class': 'cbi-input-text', + 'placeholder': _('Destination IP contains (! to exclude)') + }), + E('input', { + 'id': 'fwlive-dport', + 'class': 'cbi-input-text', + 'placeholder': _('Destination port (! to exclude)') + }) + ]) + ] + ) + ] + ), + E('div', { 'id': 'fwlive-chips', 'class': 'fwlive-chips' }, []), + E('p', { 'class': 'fwlive-hint-line' }, [ + _( + 'Click a cell to filter · ≠ on a chip to exclude · Ctrl+click a rule for firewall settings · in Simple view, click a row for the full message' + ) + ]), + E( + 'div', + { + 'id': 'fwlive-empty', + 'class': 'fwlive-empty', + 'style': 'display:none' + }, + [] + ), + E('div', { 'id': 'fwlive-scroll', 'class': 'fwlive-scroll fwlive-msg-wrap' }, [ + E('table', { 'id': 'fwlive-table', 'class': 'table cbi-section-table' }, [ + E('thead', {}, E('tr', {}, [])), + E('tbody', {}, []) + ]) + ]), + E('div', { 'class': 'fwlive-help-row' }, [ + E('details', { 'id': 'fwlive-help', 'class': 'fwlive-help' }, [ + E('summary', {}, [_('Help')]), + E('ul', {}, [ + E('li', {}, [ + _( + 'The table updates automatically when your firewall logs traffic. Use Pause if it moves too fast.' + ) + ]), + E('li', {}, [ + _( + 'Enable logging turns on WAN zone drop/reject logging only (same as Network → Firewall). It does not add rules or log normal LAN browsing.' + ) + ]), + E('li', {}, [ + _( + 'Display options on the bar set Limit, row tint, palette, and hostnames.' + ) + ]), + E('li', {}, [ + _( + 'The rate shown for WAN logging is the firewall zone log_limit. OpenWrt defaults to 10/minute when no explicit limit is configured; fwlive does not impose this cap.' + ) + ]), + E('li', { 'id': 'fwlive-manual-test' }, []), + E('li', {}, [ + _( + 'Click a row (Time or other non-link cells) to see the full log line (Simple view).' + ) + ]), + E('li', {}, [ + _( + 'Click an IP, action, or protocol to filter; use the Protocol menu (or ≠ on a chip) to exclude.' + ) + ]), + E('li', {}, [ + _( + 'Row tint shows pass/deny row backgrounds when checked. Choose Classic (green/red, default) or Accessible (teal/orange). Action text stays colored either way.' + ) + ]), + E('li', {}, [ + _('Use Detail for all columns (flags, length, raw message).') + ]), + E('li', {}, [ + _( + 'If Row tint looks missing, the active LuCI theme may omit success/error or info/warn CSS variables; fwlive falls back to local colors (air-gapped, no data leaves the device).' + ) + ]) + ]) + ]), + E( + 'span', + { + 'id': 'fwlive-build', + 'class': 'fwlive-build', + 'title': 'luci-app-fwlive' + }, + ['v' + constants.APP_VERSION] + ) + ]) + ] + ); }, addFooter() { @@ -1758,7 +1798,6 @@ return view.extend({ const testLi = document.getElementById('fwlive-manual-test'); if (testLi) logging.renderManualTestNodes(testLi, { firewallBackend: this.firewallBackend }, {}); - if (this.showHostnames) - this.resolveHostnamesForEntries(this.filteredRows()); + if (this.showHostnames) this.resolveHostnamesForEntries(this.filteredRows()); } }); diff --git a/luci-app-fwlive/root/usr/libexec/fwlive-is-firewall-event.sh b/luci-app-fwlive/root/usr/libexec/fwlive-is-firewall-event.sh index 1509f35b0..8e2022422 100755 --- a/luci-app-fwlive/root/usr/libexec/fwlive-is-firewall-event.sh +++ b/luci-app-fwlive/root/usr/libexec/fwlive-is-firewall-event.sh @@ -4,6 +4,7 @@ # GENERATED FILE — do not edit. Run: ./scripts/gen-all.sh # source: core/fwlive-log.js CLASSIFY_SPEC # Shared isFirewallEvent parity logic (shell). Sourced by fwlive-log-filter.sh and tests. +# Sourced library: do not add set -euo here (callers own strict mode, #291 C3). # One awk process classifies a batch (MODE=json) or one message (default). _fwlive_run_classify() { diff --git a/luci-app-fwlive/root/usr/libexec/fwlive-log-filter.sh b/luci-app-fwlive/root/usr/libexec/fwlive-log-filter.sh index 2f3226ef6..cc30b6119 100755 --- a/luci-app-fwlive/root/usr/libexec/fwlive-log-filter.sh +++ b/luci-app-fwlive/root/usr/libexec/fwlive-log-filter.sh @@ -8,6 +8,14 @@ # # Perf (#219): one jsonfilter for @.log[*] plus one awk classify. Process # count is constant per poll, not O(entries). +# +# Entry point (pipeline). The classifier sibling is sourced and must not +# set -euo itself (#291 C3). +set -eu +# pipefail: BusyBox ash supports it; Debian dash (host `sh`) rejects a +# bare `set -o pipefail`. Probe in a subshell (same class as #244). +# shellcheck disable=SC3040 # pipefail is ash/bash; dash probe is a subshell +(set -o pipefail) 2>/dev/null && set -o pipefail if ! command -v jsonfilter >/dev/null 2>&1; then command -v logger >/dev/null 2>&1 && logger -t fwlive "jsonfilter not found; cannot filter firewall logs" @@ -26,5 +34,7 @@ printf '%s' '{"log":[' # Prefer stdin over -s: Linux MAX_ARG_STRLEN is 128KiB; a raised logd ring # (or paused FETCH_LINES_MAX poll) can exceed that and make jsonfilter fail # while this script still printed {"log":[]} and exited 0 (#234). -printf '%s' "$input" | jsonfilter -e '@.log[*]' 2>/dev/null | _fwlive_filter_json_entries +# jsonfilter miss / empty @.log is not fatal; must still close JSON (#220). +# set -e + pipefail cannot apply to this pipeline (#291 C3). +printf '%s' "$input" | jsonfilter -e '@.log[*]' 2>/dev/null | _fwlive_filter_json_entries || true printf '%s' ']}' diff --git a/luci-app-fwlive/root/usr/libexec/fwlive-logging.sh b/luci-app-fwlive/root/usr/libexec/fwlive-logging.sh index 5e5ea2809..b9a67accd 100755 --- a/luci-app-fwlive/root/usr/libexec/fwlive-logging.sh +++ b/luci-app-fwlive/root/usr/libexec/fwlive-logging.sh @@ -3,6 +3,11 @@ # Copyright 2025-2026 Lucas Albers # # WAN zone logging helpers for ubus fwlive (logging_status / enable / disable). +# +# Sourced library (rpcd plugin, package prerm). Do not `set -euo pipefail` +# here: prerm is best-effort (`restore ... || logger`; exit 0) and callers +# expect soft failures. The rpcd entry point enables strict mode; critical +# paths use explicit `|| return 1` / `|| true` (#244, #291 C3). NF_LOG_IPV4='/proc/sys/net/netfilter/nf_log/2' NF_LOG_IPV6='/proc/sys/net/netfilter/nf_log/10' @@ -121,11 +126,16 @@ find_wan_zone_section() { # Match anonymous (@zone[N]) and named (e.g. wan) sections whose name option # is 'wan'. Prefer the first section whose type is zone (issue #168); skip # non-zone sections that happen to share name='wan'. + # uci missing / no wan zone is empty, not fatal. pipefail + set -e + # cannot apply to this pipeline (#291 C3). _zones=$(uci -q show firewall 2>/dev/null \ - | sed -n "s/^firewall\.\([^.]*\)\.name='wan'$/\1/p") + | sed -n "s/^firewall\.\([^.]*\)\.name='wan'$/\1/p") || true for zone in $_zones; do [ -n "$zone" ] || continue - [ "$(uci -q get "firewall.${zone}" 2>/dev/null)" = "zone" ] || continue + # uci -q get exits 1 on a missing section. Capture with || true so + # set -e cannot abort inside "$(…)" before || continue (#291 C3). + _type=$(uci -q get "firewall.${zone}" 2>/dev/null || true) + [ "$_type" = "zone" ] || continue printf '%s' "$zone" return 0 done @@ -133,14 +143,16 @@ find_wan_zone_section() { } firewall_changes_pending() { - pending="$(uci -q changes firewall 2>/dev/null)" + # uci miss is "no pending changes". set -e cannot apply (#291 C3). + pending="$(uci -q changes firewall 2>/dev/null || true)" [ -n "$pending" ] } wan_zone_log_value() { zone="$1" [ -n "$zone" ] || return 1 - uci -q get "firewall.${zone}.log" 2>/dev/null + # Unset option is a valid empty value; uci -q get exits 1 (#291 C3). + uci -q get "firewall.${zone}.log" 2>/dev/null || true } # Resolve a firewall section id to its canonical cfgXXXX form (issue B-1 / @@ -284,7 +296,8 @@ maybe_snapshot_wan_log_baseline() { restore_wan_log_baseline() { path="$(wan_log_baseline_path)" [ -f "$path" ] || return 0 - baseline=$(cat "$path" 2>/dev/null) + # Empty file is a valid "option was unset" baseline (#291 C3). + baseline=$(cat "$path" 2>/dev/null || true) zone=$(find_wan_zone_section) if [ -z "$zone" ]; then logger -t fwlive "WAN log baseline restore skipped: no WAN zone" 2>/dev/null || true @@ -363,7 +376,7 @@ wan_filter_log_clear_value() { read_nf_log_backend() { path="$1" [ -f "$path" ] || return 1 - val=$(cat "$path" 2>/dev/null) + val=$(cat "$path" 2>/dev/null) || return 1 [ -n "$val" ] && [ "$val" != 'none' ] } @@ -403,8 +416,9 @@ collect_logging_blockers() { check_nf_log_ipv4 || logging_blockers_append 'nf_log_ipv4_missing' check_nf_log_ipv6 || logging_blockers_append 'nf_log_ipv6_missing' - [ -n "$LOGGING_BLOCKERS" ] || return 0 - return 1 + # Report via LOGGING_BLOCKERS, not exit status: return 1 would abort + # build_logging_status_json under set -e (#291 C3). + return 0 } collect_logging_warnings() { @@ -414,8 +428,8 @@ collect_logging_warnings() { # Warnings are diagnostics only — do not gate the enable-logging CTA. command -v timeout >/dev/null 2>&1 || logging_warnings_append 'timeout_missing' - [ -n "$LOGGING_WARNINGS" ] || return 0 - return 1 + # Report via LOGGING_WARNINGS, not exit status (#291 C3). + return 0 } json_null_or_string() { @@ -430,8 +444,13 @@ json_null_or_string() { build_logging_status_json() { zone=$(find_wan_zone_section) - log_val=$(wan_zone_log_value "$zone") - limit_val=$( [ -n "$zone" ] && uci -q get "firewall.${zone}.log_limit" 2>/dev/null ) + # Empty zone / unset log bit are valid; set -e cannot apply (#291 C3). + log_val=$(wan_zone_log_value "$zone") || log_val= + # Unset log_limit is a valid empty value; uci -q get exits 1 (#291 C3). + limit_val= + if [ -n "$zone" ]; then + limit_val=$(uci -q get "firewall.${zone}.log_limit" 2>/dev/null || true) + fi wan_log=false if wan_filter_log_enabled "$log_val"; then wan_log=true diff --git a/luci-app-fwlive/root/usr/libexec/rpcd/fwlive b/luci-app-fwlive/root/usr/libexec/rpcd/fwlive index da151eae7..d4175bc62 100755 --- a/luci-app-fwlive/root/usr/libexec/rpcd/fwlive +++ b/luci-app-fwlive/root/usr/libexec/rpcd/fwlive @@ -8,6 +8,17 @@ # resolve — reverse DNS for IP addresses (BusyBox nslookup) # logging_status — WAN zone logging readiness (no args) # enable_wan_logging / disable_wan_logging — opt-in WAN zone log=1 (no args) +# +# Entry point (rpcd plugin). Sourced helpers inherit these options. Do not +# copy set -euo into fwlive-logging.sh — prerm sources that file alone (#222) +# and keeps soft-fail / || return 1 contracts (#244, #291 C3). +set -eu +# pipefail: BusyBox ash (device) supports it; Debian dash (host `sh` in +# CI) rejects `set -o pipefail` as a startup abort. Probe in a subshell — +# a failed `set` in-process is fatal even under `if` (same class as #244 +# failed exec). +# shellcheck disable=SC3040 # pipefail is ash/bash; dash probe is a subshell +(set -o pipefail) 2>/dev/null && set -o pipefail FW4_TAG='!fw4: ' LIBEXEC_DIR="$(cd "$(dirname "$0")/.." && pwd)" @@ -83,7 +94,9 @@ map_add() { } uci_rule_names() { - uci -q show firewall 2>/dev/null | sed -n "s/^firewall\.@rule\[[0-9]*\]\.name='\(.*\)'$/\1/p" + # uci missing / empty firewall config is "no names", not fatal. + # pipefail + set -e cannot apply to this pipeline (#291 C3). + uci -q show firewall 2>/dev/null | sed -n "s/^firewall\.@rule\[[0-9]*\]\.name='\(.*\)'$/\1/p" || true } is_uci_style_name() { @@ -396,7 +409,8 @@ poll_lines_from_input() { json_get_var first 1 json_select .. # shellcheck disable=SC2154 # set by json_get_var above - case "$first" in + # Missing addresses[1] leaves first unset; set -u (#291 C3). + case "${first:-}" in ''|*[!0-9]*) ;; *) lines="$first" ;; esac @@ -462,9 +476,10 @@ resolve_hostname() { [ -n "$ip" ] || return 1 is_resolvable_address "$ip" || return 1 command -v nslookup >/dev/null 2>&1 || return 1 - out=$(run_with_timeout "$RESOLVE_TIMEOUT" nslookup "$ip") + # timeout/nslookup miss is "no PTR", not a plugin abort (#291 C3). + out=$(run_with_timeout "$RESOLVE_TIMEOUT" nslookup "$ip") || return 1 [ -n "$out" ] || return 1 - name=$(parse_nslookup_name "$out") + name=$(parse_nslookup_name "$out") || return 1 [ -n "$name" ] || return 1 printf '%s' "$name" } @@ -598,11 +613,14 @@ resolve_addresses() { now=$(date +%s) [ $((now - start)) -ge "$RESOLVE_BUDGET" ] && break json_get_var ip "$idx" - if ! is_resolvable_address "$ip"; then + # Missing/empty element: set -u cannot read bare $ip (#291 C3). + if ! is_resolvable_address "${ip:-}"; then idx=$((idx + 1)) continue fi - name=$(resolve_hostname "$ip") + # NXDOMAIN / timeout: resolve_hostname returns 1. set -e + # cannot apply to this assignment (#291 C3). + name=$(resolve_hostname "$ip") || name= if [ -n "$name" ]; then map_add "$ip" "$name" count=$((count + 1)) @@ -789,6 +807,17 @@ Address: 127.0.0.1:53") run_logging_selftest || return 1 + # Strict-mode smoke (#291 C3): status JSON uses soft-fail helpers + # (uci miss, empty WAN zone). Must not abort under dash/ash set -e. + _status=$(build_logging_status_json) || { + echo "build_logging_status_json: aborted under set -e" >&2 + return 1 + } + case "$_status" in + *'"blockers":'*) ;; + *) echo "build_logging_status_json: missing blockers: $_status" >&2; return 1 ;; + esac + if ! command -v jshn >/dev/null 2>&1; then echo "skip: jshn not available (poll cap via jshn not tested)" >&2 return 0 @@ -834,7 +863,7 @@ Address: 127.0.0.1:53") return 0 } -case "$1" in +case "${1:-}" in __selftest) run_selftest exit $? @@ -845,22 +874,22 @@ case "$1" in exit $? ;; __poll_clamp) - poll_clamp_lines "$2" + poll_clamp_lines "${2:-}" exit $? ;; __tmp_dir_ok) - _fwlive_tmp_dir_ok "$2" + _fwlive_tmp_dir_ok "${2:-}" exit $? ;; __resolve_one) - if name=$(resolve_hostname "$2"); then + if name=$(resolve_hostname "${2:-}"); then printf '%s\n' "$name" exit 0 fi exit 1 ;; __parse_nslookup) - name=$(parse_nslookup_name "$2") + name=$(parse_nslookup_name "${2:-}") if [ -n "$name" ]; then printf '%s\n' "$name" exit 0 @@ -874,15 +903,16 @@ case "$1" in echo '{"rules":{},"poll":{"addresses":[]},"resolve":{"addresses":[]},"logging_status":{},"enable_wan_logging":{},"disable_wan_logging":{}}' ;; call) - case "$2" in + case "${2:-}" in rules) build_rules_map ;; poll) - poll_logs "$3" + # rpcd may omit argv $3 and pass JSON on stdin (#291 C3). + poll_logs "${3:-}" ;; resolve) - resolve_addresses "$3" + resolve_addresses "${3:-}" ;; logging_status) build_logging_status_json diff --git a/luci-app-wwand/Makefile b/luci-app-wwand/Makefile index 91912a430..de03d4cb2 100644 --- a/luci-app-wwand/Makefile +++ b/luci-app-wwand/Makefile @@ -15,12 +15,12 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-wwand -PKG_RELEASE:=10 +PKG_RELEASE:=11 PKG_SOURCE_PROTO:=git PKG_SOURCE_URL:=https://github.com/ddimension/luci-app-wwand.git -PKG_SOURCE_VERSION:=bd773d76198d2a92063b349ed4f1e1f75475857c -PKG_SOURCE_DATE:=2026-09-05 +PKG_SOURCE_VERSION:=6fd28614e5e8d40010119d52b83cf78908e2a7d3 +PKG_SOURCE_DATE:=2026-09-06 PKG_MIRROR_HASH:=skip PKG_LICENSE:=GPL-2.0-only diff --git a/luci-mod-system/root/usr/share/rpcd/acl.d/luci-mod-system.json b/luci-mod-system/root/usr/share/rpcd/acl.d/luci-mod-system.json index af5a2bd72..f3b24f60e 100644 --- a/luci-mod-system/root/usr/share/rpcd/acl.d/luci-mod-system.json +++ b/luci-mod-system/root/usr/share/rpcd/acl.d/luci-mod-system.json @@ -154,6 +154,7 @@ "/dev/mtdblock[0-9]*": [ "read" ], "/etc/sysupgrade.conf": [ "read" ], "/lib/upgrade/platform.sh": [ "list" ], + "/proc/[0-9]*/mounts": [ "read" ], "/proc/mounts": [ "read" ], "/proc/mtd": [ "read" ], "/proc/partitions": [ "read" ], diff --git a/luci-proto-wwand/Makefile b/luci-proto-wwand/Makefile index 3819d3876..c96d2c980 100644 --- a/luci-proto-wwand/Makefile +++ b/luci-proto-wwand/Makefile @@ -18,12 +18,12 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-proto-wwand -PKG_RELEASE:=6 +PKG_RELEASE:=7 PKG_SOURCE_PROTO:=git PKG_SOURCE_URL:=https://github.com/ddimension/luci-proto-wwand.git -PKG_SOURCE_VERSION:=cc0bbfe522c7bc941a25f1ad14dfb2874660311b -PKG_SOURCE_DATE:=2026-09-02 +PKG_SOURCE_VERSION:=b2cbfa2150034e7037a966e4676f301fcea6ed9e +PKG_SOURCE_DATE:=2026-09-06 PKG_MIRROR_HASH:=skip PKG_LICENSE:=GPL-2.0-only diff --git a/wwand/Makefile b/wwand/Makefile index eb2f47d6f..5d771eefd 100644 --- a/wwand/Makefile +++ b/wwand/Makefile @@ -43,11 +43,11 @@ include $(TOPDIR)/rules.mk PKG_NAME:=wwand -PKG_RELEASE:=23 +PKG_RELEASE:=24 PKG_SOURCE_PROTO:=git PKG_SOURCE_URL:=https://github.com/ddimension/wwand.git -PKG_SOURCE_VERSION:=b9708dfd3fab8b180d3f66a66f1ea8dbce63adbf +PKG_SOURCE_VERSION:=6ddec23e3ea9c6c6bbe1cd5feef207972137d840 PKG_SOURCE_DATE:=2026-09-06 PKG_MIRROR_HASH:=skip