Files

535 lines
14 KiB
JavaScript

'use strict';
/* SPDX-License-Identifier: Apache-2.0 */
/* Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com> */
'require baseclass';
/**
* Shared classify logic mirrors core/fwlive-log.js CLASSIFY_SPEC — keep in sync
* (gen-luci-wrapper.js gates full-spec drift; ./scripts/gen-all.sh verifies).
* LuCI-only helpers live in the @fwlive-codegen:luci-preserve region.
*/
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'
],
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'] }] }
]
},
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,
wordPattern: function (words) {
const alt = words.join('|');
return new RegExp('(^|[^A-Za-z0-9_])(' + alt + ')([^A-Za-z0-9_]|$)', 'i');
},
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,
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) {
return (message || '').replace(this.NETFILTER_KV_GLUE, '$1 ');
},
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];
return out;
},
detectAction: function (message) {
const m = message.match(this.ACTION_RE);
return m ? m[2].toUpperCase() : 'UNKNOWN';
},
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) {
for (let i = 0; i < keys.length; i++) {
if (has(keys[i])) return true;
}
return false;
};
const pred = {
kv: function (c) {
for (let i = 0; i < c.kv.length; i++) {
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);
}
};
const evalNode = function (node) {
if (node.and) {
for (let i = 0; i < node.and.length; i++) {
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;
}
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);
}
return false;
};
for (let i = 0; i < this.CLASSIFY_SPEC.rules.length; i++) {
if (evalNode(this.CLASSIFY_SPEC.rules[i])) return true;
}
return false;
},
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';
return 'unknown';
},
parseRuleHint: function (message) {
let msg = this.normalizeNetfilterMessage(message || '').trim();
msg = msg.replace(/^\[\s*[\d.]+\]\s*/, '');
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];
const colon = msg.match(/^([A-Za-z0-9_.-]+):/);
if (colon) {
const tag = colon[1].toLowerCase();
if (tag !== 'kernel' && tag !== 'iptables') return colon[1];
}
return '';
},
formatRuleLabel: function (hint) {
if (!hint) return '';
if (hint === 'fw4') return 'Firewall4';
return hint.replace(/-/g, ' ');
},
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 (/^kernel:/i.test(msg.trim())) return 'UNKNOWN';
const hasTuple = !!(kv.IN || kv.OUT) && !!(kv.SRC || kv.DST || kv.PROTO);
if (hasTuple) return 'PASS';
return 'UNKNOWN';
},
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 '';
return m[0].trim().toUpperCase().replace(/\s+/g, ',');
},
parseLength: function (kv) {
const len = kv.LEN || kv.LENGTH || '';
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;
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);
}
const n = Number(entry.time);
if (!isFinite(n)) return null;
return n > 1e12 ? Math.floor(n / 1000) : Math.floor(n);
},
formatTimestampDisplay: function (entry) {
const unix = this.timestampUnix(entry);
if (unix == null) return '';
return new Date(unix * 1000).toISOString();
},
/* @fwlive-codegen:luci-preserve-begin */
formatTimestampLocal: function (unix) {
if (unix == null || !isFinite(unix)) return '';
const d = new Date(unix * 1000);
const pad = function (n) {
return (n < 10 ? '0' : '') + n;
};
return '%d-%s-%s %s:%s:%s'.format(
d.getFullYear(),
pad(d.getMonth() + 1),
pad(d.getDate()),
pad(d.getHours()),
pad(d.getMinutes()),
pad(d.getSeconds())
);
},
formatTimestampCompact: function (unix) {
if (unix == null || !isFinite(unix)) return '';
const d = new Date(unix * 1000);
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) {
const src = row && row.src ? String(row.src) : '';
const dst = row && row.dst ? String(row.dst) : '';
const sport = row && row.sport ? String(row.sport) : '';
const dport = row && row.dport ? String(row.dport) : '';
let left = src;
let right = dst;
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;
return left + ' → ' + right;
},
formatCell: function (value) {
if (value == null || value === '') return '';
return String(value);
},
formatActionLabel: function (action) {
const a = (action || '').toLowerCase();
if (!a || a === 'unknown') return '—';
return a;
},
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 (m.length > 240) return m.substring(0, 237) + '…';
return m;
},
/* @fwlive-codegen:luci-preserve-end */
isFirewallEvent: function (entry) {
const msg = this.normalizeNetfilterMessage((entry && entry.msg) || '');
if (!msg.trim()) 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;
return [
tsUnix,
action,
src,
dst,
sport,
dport,
proto,
ifaceIn,
ifaceOut,
entry.msg || ''
].join('|');
},
extractAddrs: function (kv) {
return {
src: kv.SRC || '',
dst: kv.DST || '',
sport: kv.SPT || '',
dport: kv.DPT || ''
};
},
extractIfaces: function (kv) {
const ifaceIn = kv.IN || '';
const ifaceOut = kv.OUT || '';
return {
ifaceIn: ifaceIn,
ifaceOut: ifaceOut,
iface: ifaceIn || ifaceOut || '',
dir: ifaceIn && ifaceOut ? 'forward' : ifaceIn ? 'in' : ifaceOut ? 'out' : 'unknown'
};
},
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 action = this.normalizeAction(actionRaw);
const addrs = this.extractAddrs(kv);
const ifs = this.extractIfaces(kv);
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,
addrs.src,
addrs.dst,
addrs.sport,
addrs.dport,
proto,
ifs.ifaceIn,
ifs.ifaceOut
),
log_id: entry && entry.id != null ? Number(entry.id) : null,
timestamp: tsUnix,
timestamp_display: tsDisplay,
rule_hint: ruleHint,
rule_label: ruleLabel,
action: action,
action_raw: actionRaw,
interface: ifs.iface,
interface_in: ifs.ifaceIn,
interface_out: ifs.ifaceOut,
direction: ifs.dir,
proto: proto,
src: addrs.src,
sport: addrs.sport,
dst: addrs.dst,
dport: addrs.dport,
flags: flags,
length: length,
message: entry.msg || ''
};
},
parseFilterValue: function (val) {
const s = (val || '').trim();
if (!s) return { negate: false, value: '' };
if (s.charAt(0) === '!') return { negate: true, value: s.slice(1).trim() };
return { negate: false, value: s };
},
toggleFilterNegation: function (val) {
const p = this.parseFilterValue(val);
if (!p.value) return val;
return p.negate ? p.value : '!' + p.value;
},
formatFilterChipLabel: function (field, val) {
const p = this.parseFilterValue(val);
if (!p.value) return '';
if (p.negate) {
if (field === 'q' || field === 'src' || field === 'dst')
return '%s: not contains %s'.format(field, p.value);
return '%s: not %s'.format(field, p.value);
}
return '%s: %s'.format(field, val);
},
matchesTextField: function (haystack, spec) {
const p = this.parseFilterValue(spec);
if (!p.value) return true;
const hit = (haystack || '').indexOf(p.value) !== -1;
return p.negate ? !hit : hit;
},
matchesExactField: function (haystack, spec) {
const p = this.parseFilterValue(spec);
if (!p.value) return true;
const want = p.value.toUpperCase();
const got = (haystack || '').toUpperCase();
const hit = got === want;
return p.negate ? !hit : hit;
},
matchesQueryField: function (row, spec) {
const p = this.parseFilterValue(spec);
if (!p.value) return true;
const keys = Object.keys(row);
const parts = [];
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;
return p.negate ? !hit : hit;
},
matchesActionField: function (row, spec) {
const p = this.parseFilterValue(spec);
if (!p.value) return true;
const want = p.value.toLowerCase();
const hit =
row.action === want || (row.action_raw || '').toUpperCase() === p.value.toUpperCase();
return p.negate ? !hit : hit;
},
matchesInterfaceField: function (row, spec) {
const p = this.parseFilterValue(spec);
if (!p.value) return true;
const iface = p.value;
const hit =
row.interface === iface || row.interface_in === iface || row.interface_out === iface;
return p.negate ? !hit : hit;
},
matchesFilter: function (row, filters) {
if (filters.q && !this.matchesQueryField(row, filters.q)) return false;
if (filters.action && !this.matchesActionField(row, filters.action)) return false;
if (filters.interface && !this.matchesInterfaceField(row, filters.interface)) 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) {
const a = (action || '').toLowerCase();
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';
}
});