🐤 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

@@ -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