🐤 Sync 2026-09-07 02:18:00

This commit is contained in:
github-actions[bot] committed 2026-09-07 02:18:00 +08:00
1 parent 65e584daaa
commit 4b545cbffa
30 files changed
+1911 -1238

No files matched your search

+3 -2
View File
@@ -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
@@ -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');
+6
View File
@@ -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 ""
+6
View File
@@ -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"
+6
View File
@@ -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"
@@ -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"
@@ -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
@@ -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" ],
+5 -5
View File
@@ -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-<token> 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-<token> and points daed's importSubscription at
# http://127.0.0.1/cgi-bin/daede-sub?t=<token>. 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'
+1 -1
View File
@@ -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
@@ -1,4 +1,6 @@
'use strict';
/* SPDX-License-Identifier: Apache-2.0 */
/* Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com> */
'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;
}
@@ -1,4 +1,6 @@
'use strict';
/* SPDX-License-Identifier: Apache-2.0 */
/* Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com> */
'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({
@@ -1,4 +1,6 @@
'use strict';
/* SPDX-License-Identifier: Apache-2.0 */
/* Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com> */
'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'
]
}
});
@@ -1,4 +1,6 @@
'use strict';
/* SPDX-License-Identifier: Apache-2.0 */
/* Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com> */
'require baseclass';
/**
@@ -1,4 +1,6 @@
'use strict';
/* SPDX-License-Identifier: Apache-2.0 */
/* Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com> */
'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;
@@ -1,4 +1,6 @@
'use strict';
/* SPDX-License-Identifier: Apache-2.0 */
/* Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com> */
'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({
@@ -1,4 +1,6 @@
'use strict';
/* SPDX-License-Identifier: Apache-2.0 */
/* Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com> */
'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';
}
});
@@ -1,4 +1,6 @@
'use strict';
/* SPDX-License-Identifier: Apache-2.0 */
/* Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com> */
'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.')));
}
}
@@ -1,4 +1,6 @@
'use strict';
/* SPDX-License-Identifier: Apache-2.0 */
/* Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com> */
'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;
@@ -1,4 +1,6 @@
'use strict';
/* SPDX-License-Identifier: Apache-2.0 */
/* Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com> */
'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') || '—'
])
])
])
]));
);
}
}
}
@@ -1,4 +1,6 @@
'use strict';
/* SPDX-License-Identifier: Apache-2.0 */
/* Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com> */
'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;
}
File diff suppressed because it is too large. Load diff
@@ -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() {
@@ -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' ']}'
@@ -3,6 +3,11 @@
# Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com>
#
# 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
+44 -14
View File
@@ -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
+3 -3
View File
@@ -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
@@ -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" ],
+3 -3
View File
@@ -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
+2 -2
View File
@@ -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