From bf175fd51a9cfc33be0c19faddda714854065180 Mon Sep 17 00:00:00 2001 From: Jo-Philipp Wich Date: Fri, 21 Jan 2022 20:22:22 +0100 Subject: [PATCH] luci-mod-status: add nftables status page Signed-off-by: Jo-Philipp Wich --- .../resources/view/status/nftables.js | 663 ++++++++++++++++++ .../share/luci/menu.d/luci-mod-status.json | 21 +- .../usr/share/rpcd/acl.d/luci-mod-status.json | 1 + 3 files changed, 683 insertions(+), 2 deletions(-) create mode 100644 modules/luci-mod-status/htdocs/luci-static/resources/view/status/nftables.js diff --git a/modules/luci-mod-status/htdocs/luci-static/resources/view/status/nftables.js b/modules/luci-mod-status/htdocs/luci-static/resources/view/status/nftables.js new file mode 100644 index 0000000000..c40ec83388 --- /dev/null +++ b/modules/luci-mod-status/htdocs/luci-static/resources/view/status/nftables.js @@ -0,0 +1,663 @@ +'use strict'; +'require view'; +'require poll'; +'require fs'; +'require ui'; +'require dom'; +'require tools.firewall as fwtool'; + +var expr_translations = { + 'meta.iifname': _('Ingress device name', 'nft meta iifname'), + 'meta.oifname': _('Egress device name', 'nft meta oifname'), + 'meta.iif': _('Ingress device id', 'nft meta iif'), + 'meta.iif': _('Engress device id', 'nft meta oif'), + + 'meta.l4proto': _('IP protocol', 'nft meta l4proto'), + 'meta.l4proto.tcp': 'TCP', + 'meta.l4proto.udp': 'UDP', + 'meta.l4proto.icmp': 'ICMP', + 'meta.l4proto.icmpv6': 'ICMPv6', + 'meta.l4proto.ipv6-icmp': 'ICMPv6', + + 'meta.nfproto': _('Address family', 'nft meta nfproto'), + 'meta.nfproto.ipv4': 'IPv4', + 'meta.nfproto.ipv6': 'IPv6', + + 'meta.mark': _('Connection mark', 'nft meta mark'), + + 'ct.state': _('Conntrack state', 'nft ct state'), + + 'ct.status': _('Conntrack status', 'nft ct status'), + 'ct.status.dnat': 'DNAT', + + 'ip.protocol': _('IP protocol', 'nft ip protocol'), + 'ip.protocol.tcp': 'TCP', + 'ip.protocol.udp': 'UDP', + 'ip.protocol.icmp': 'ICMP', + 'ip.protocol.icmpv6': 'ICMPv6', + 'ip.protocol.ipv6-icmp': 'ICMPv6', + + 'ip.saddr': _('Source IP', 'nft ip saddr'), + 'ip.daddr': _('Destination IP', 'nft ip daddr'), + 'ip.sport': _('Source port', 'nft ip sport'), + 'ip.dport': _('Destination port', 'nft ip dport'), + 'ip6.saddr': _('Source IPv6', 'nft ip6 saddr'), + 'ip6.daddr': _('Destination IPv6', 'nft ip6 daddr'), + 'icmp.code': _('ICMPv6 code', 'nft icmpv6 code'), + 'icmp.type': _('ICMPv6 type', 'nft icmpv6 type'), + 'icmpv6.code': _('ICMPv6 code', 'nft icmpv6 code'), + 'icmpv6.type': _('ICMPv6 type', 'nft icmpv6 type'), + 'tcp.sport': _('TCP source port', 'nft tcp sport'), + 'tcp.dport': _('TCP destination port', 'nft tcp dport'), + 'udp.sport': _('UDP source port', 'nft udp sport'), + 'udp.dport': _('UDP destination port', 'nft udp dport'), + 'tcp.flags': _('TCP flags', 'nft tcp flags'), + + 'natflag.random': _('Randomize source port mapping', 'nft nat flag random'), + 'natflag.fully-random': _('Full port randomization', 'nft nat flag fully-random'), + 'natflag.persistent': _('Use same source and destination for each connection', 'nft nat flag persistent'), + + 'rt.mtu': _('Effective route MTU', 'nft rt mtu'), + + 'tcpoption.maxseg.size': _('TCP MSS', 'nft tcp option maxseg size'), + + 'unit.packets': _('packets', 'nft unit'), + 'unit.mbytes': _('MiB', 'nft unit'), + 'unit.kbytes': _('KiB', 'nft unit'), + 'unit.week': _('week', 'nft unit'), + 'unit.day': _('day', 'nft unit'), + 'unit.hour': _('hour', 'nft unit'), + 'unit.minute': _('minute', 'nft unit'), +}; + +var op_translations = { + '==': _('%s is %s', 'nft relational "==" operator expression'), + '!=': _('%s not %s', 'nft relational "!=" operator expression'), + '>=': _('%s greater than or equal to %s', 'nft relational ">=" operator expression'), + '<=': _('%s lower than or equal to %s', 'nft relational "<=" operator expression'), + '>': _('%s greater than %s', 'nft relational ">" operator expression'), + '<': _('%s lower than %s', 'nft relational "<" operator expression'), + 'in': _('%s is one of %s', 'nft relational "in" operator expression'), + 'in_set': _('%s in set %s', 'nft set match expression'), + 'not_in_set': _('%s not in set %s', 'nft not in set match expression'), +}; + +var action_translations = { + 'accept': _('Accept packet', 'nft accept action'), + 'drop': _('Drop packet', 'nft drop action'), + 'jump': _('Continue in %h', 'nft jump action'), + + 'reject.tcp reset': _('Reject packet with TCP reset', 'nft reject with tcp reset'), + 'reject.icmp': _('Reject IPv4 packet with ICMP type %h', 'nft reject with icmp type'), + 'reject.icmpv6': _('Reject packet with ICMPv6 type %h', 'nft reject with icmpv6 type'), + 'reject.icmpx': _('Reject packet with ICMP type %h', 'nft reject with icmpx type'), + + 'snat.ip.addr': _('Rewrite source to %h', 'nft snat ip to addr'), + 'snat.ip.addr.port': _('Rewrite source to %h, port %h', 'nft snat ip to addr:port'), + + 'snat.ip6.addr': _('Rewrite source to %h', 'nft snat ip6 to addr'), + 'snat.ip6.addr.port': _('Rewrite source to %h, port %h', 'nft snat ip6 to addr:port'), + + 'dnat.ip.addr': _('Rewrite destination to %h', 'nft dnat ip to addr'), + 'dnat.ip.addr.port': _('Rewrite destination to %h, port %h', 'nft dnat ip to addr:port'), + + 'dnat.ip6.addr': _('Rewrite destination to %h', 'nft dnat ip6 to addr'), + 'dnat.ip6.addr.port': _('Rewrite destination to %h, port %h', 'nft dnat ip6 to addr:port'), + + 'redirect': _('Redirect to local system', 'nft redirect'), + 'redirect.port': _('Redirect to local port %h', 'nft redirect to port'), + + 'masquerade': _('Rewrite to egress device address'), + + 'mangle': _('Set header field %s to %s', 'nft mangle'), + + 'limit': _('At most %h per %h, burst of %h'), + 'limit.burst': _('At most %h per %h, burst of %h'), + 'limit.inv': _('At least %h per %h, burst of %h'), + 'limit.inv.burst': _('At least %h per %h, burst of %h'), + + 'return': _('Continue in calling chain'), + + 'flow': _('Utilize flow table %h') +}; + +return view.extend({ + load: function() { + return L.resolveDefault(fs.exec_direct('/usr/sbin/nft', [ '--json', 'list', 'ruleset' ], 'json'), {}); + }, + + isActionExpression: function(expr) { + for (var k in expr) { + if (expr.hasOwnProperty(k)) { + switch (k) { + case 'accept': + case 'reject': + case 'drop': + case 'jump': + case 'snat': + case 'dnat': + case 'redirect': + case 'mangle': + case 'masquerade': + case 'return': + case 'flow': + return true; + } + } + } + + return false; + }, + + exprToKey: function(expr) { + var kind, spec; + + if (!Array.isArray(expr) && typeof(expr) == 'object') { + for (var k in expr) { + if (expr.hasOwnProperty(k)) { + kind = k; + spec = expr[k]; + break; + } + } + } + + switch (kind || '-') { + case 'meta': + case 'ct': + case 'rt': + return '%h.%h'.format(kind, spec.key); + + case 'payload': + return '%h.%h'.format(spec.protocol, spec.field); + + case 'tcp option': + return 'tcpoption.%h.%h'.format(spec.name, spec.field); + + case 'reject': + return 'reject.%h'.format(spec.type); + } + + return null; + }, + + exprToString: function(expr, hint) { + var kind, spec; + + if (typeof(expr) != 'object') { + var s; + + if (hint) + s = expr_translations['%s.%h'.format(hint, expr)]; + + return s || '%h'.format(expr); + } + + if (Array.isArray(expr)) { + kind = 'list'; + spec = expr; + } + else { + for (var k in expr) { + if (expr.hasOwnProperty(k)) { + kind = k; + spec = expr[k]; + } + } + } + + if (!kind) + return ''; + + switch (kind) { + case 'prefix': + return '%h/%d'.format(spec.addr, spec.len); + + case 'set': + case 'list': + var items = [], + lis = []; + + for (var i = 0; i < spec.length; i++) { + items.push('%s'.format(this.exprToString(spec[i]))); + lis.push('%s'.format(this.exprToString(spec[i]))); + } + + var tpl; + + if (kind == 'set') + tpl = '
{ %s }
%s
'; + else + tpl = '
%s
%s
'; + + return tpl.format(items.join(', '), lis.join('
')); + + case 'concat': + var items = []; + + for (var i = 0; i < spec.length; i++) + items.push(this.exprToString(spec[i])); + + return items.join('+'); + + case 'range': + return '%s-%s'.format(this.exprToString(spec[0], hint), this.exprToString(spec[1], hint)); + + case '&': + case '|': + case '^': + return '%s %h %s'.format( + this.exprToString(spec[0], hint), + kind, + Array.isArray(spec[1]) ? '(%h)'.format(spec[1].join('|')) : this.exprToString(spec[1], hint)); + + default: + var k = this.exprToKey(expr); + + if (k) + return expr_translations[k] || '%s'.format(k); + + return '%s: %s'.format(kind, JSON.stringify(spec)); + } + }, + + renderMatchExpr: function(spec) { + switch (spec.op) { + case '==': + case '!=': + if ((typeof(spec.right) == 'object' && spec.right.set) || + (typeof(spec.right) == 'string' && spec.right.charAt(0) == '@')) + spec.op = (spec.op == '==') ? 'in_set' : 'not_in_set'; + + break; + + case 'in': + if (typeof(spec.right) != 'object') + spec.op = '=='; + + break; + } + + return E('span', { 'class': 'ifacebadge' }, + (op_translations[spec.op] || '%%s %h %%s'.format(spec.op)).format( + this.exprToString(spec.left), + this.exprToString(spec.right, this.exprToKey(spec.left)) + ) + ); + }, + + renderNatFlags: function(spec) { + var f = []; + + if (spec && Array.isArray(spec.flags)) { + for (var i = 0; i < spec.flags.length; i++) + f.push(expr_translations['natflag.%h'.format(spec.flags[i])] || spec.flags[i]); + } + + return f.length ? E('small', { 'class': 'cbi-tooltip-container' }, [ + ' (', + N_(f.length, '1 flag', '%d flags', 'nft amount of flags').format(f.length), + ')', + E('span', { 'class': 'cbi-tooltip' }, f.join('
')) + ]) : E([]); + }, + + renderRateUnit: function(value, unit) { + if (!unit) + unit = 'packets'; + + return '%d\xa0%s'.format( + value, + expr_translations['unit.%h'.format(unit)] || unit + ); + }, + + renderExpr: function(expr, table) { + var kind, spec; + + for (var k in expr) { + if (expr.hasOwnProperty(k)) { + kind = k; + spec = expr[k]; + } + } + + if (!kind) + return E([]); + + switch (kind) { + case 'match': + return this.renderMatchExpr(spec); + + case 'reject': + var k = 'reject.%s'.format(spec.type); + + return E('span', { + 'class': 'ifacebadge', + 'data-tooltip': JSON.stringify(spec) + }, (action_translations[k] || k).format(this.exprToString(spec.expr))); + + case 'accept': + case 'drop': + return E('span', { + 'class': 'ifacebadge' + }, action_translations[kind] || '%h'.format(kind)); + + case 'jump': + return E('span', { + 'class': 'ifacebadge' + }, action_translations.jump.format(table, spec.target, spec.target)); + + case 'return': + return E('span', { + 'class': 'ifacebadge' + }, action_translations.return); + + case 'snat': + case 'dnat': + var k = '%h.%h'.format(kind, spec.family), + a = []; + + if (spec.addr) { + k += '.addr'; + a.push(this.exprToString(spec.addr)); + } + + if (spec.port) { + k += '.port'; + a.push(this.exprToString(spec.port)); + } + + return E('span', { 'class': 'ifacebadge' }, [ + E('span', ''.format.apply(action_translations[k] || k, a)), + this.renderNatFlags(spec) + ]); + + case 'redirect': + var k = 'redirect', + a = []; + + if (spec && spec.port) { + k += '.port'; + a.push(this.exprToString(spec.port)); + } + + return E('span', { 'class': 'ifacebadge' }, [ + E('span', ''.format.apply(action_translations[k] || k, a)), + this.renderNatFlags(spec) + ]); + + case 'masquerade': + return E('span', { 'class': 'ifacebadge' }, [ + E('span', action_translations.masquerade), + this.renderNatFlags(spec) + ]); + + case 'mangle': + return E('span', { 'class': 'ifacebadge' }, + action_translations.mangle.format( + this.exprToString(spec.key), + this.exprToString(spec.value) + )); + + case 'limit': + var k = 'limit'; + var a = [ + this.renderRateUnit(spec.rate, spec.rate_unit), + expr_translations['unit.%h'.format(spec.per)] || spec.per + ]; + + if (spec.inv) + k += '.inv'; + + if (spec.burst) { + k += '.burst'; + a.push(this.renderRateUnit(spec.burst, spec.burst_unit)); + } + + return E('span', { 'class': 'ifacebadge', 'cbi-tooltip': JSON.stringify(spec) }, + ''.format.apply(action_translations[k] || k, a)); + + case 'flow': + return E('span', { + 'class': 'ifacebadge' + }, action_translations.flow.format(spec.add)); + + default: + return E('span', { + 'class': 'ifacebadge', + 'data-tooltip': JSON.stringify(spec) + }, [ '{ ', E('strong', {}, [ kind ]), ' }' ]); + } + }, + + renderCounter: function(data) { + return E('span', { 'class': 'ifacebadge cbi-tooltip-container nft-counter' }, [ + E('var', [ '%.1024mB'.format(data.bytes) ]), + E('div', { 'class': 'cbi-tooltip' }, [ + _('Traffic matched by rule: %.1000mPackets, %.1024mBytes', 'nft counter').format(data.packets, data.bytes) + ]) + ]); + }, + + renderComment: function(comment) { + return E('span', { 'class': 'ifacebadge cbi-tooltip-container nft-comment' }, [ + E('var', [ '#' ]), + E('div', { 'class': 'cbi-tooltip' }, [ + _('Rule comment: %s', 'nft comment').format(comment.replace(/^!fw4: /, '')) + ]) + ]); + }, + + renderRule: function(data, spec) { + var empty = true; + + var row = E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td', 'style': 'width:60%' }), + E('td', { 'class': 'td', 'style': 'width:40%' }) + ]); + + if (Array.isArray(spec.expr)) { + for (var i = 0; i < spec.expr.length; i++) { + // nftables JSON format bug, `flow` targets are currently not properly serialized + if (typeof(spec.expr[i]) == 'string' && spec.expr[i].match(/^flow add @(\S+)$/)) + spec.expr[i] = { flow: { add: RegExp.$1 } }; + + var res = this.renderExpr(spec.expr[i], spec.table); + + if (typeof(spec.expr[i]) == 'object' && spec.expr[i].counter) { + row.childNodes[0].insertBefore( + this.renderCounter(spec.expr[i].counter), + row.childNodes[0].firstChild); + } + else if (this.isActionExpression(spec.expr[i])) { + dom.append(row.childNodes[1], [ res ]); + } + else { + dom.append(row.childNodes[0], [ res ]); + empty = false; + } + } + } + + if (spec.comment) { + row.childNodes[0].insertBefore( + this.renderComment(spec.comment), + row.childNodes[0].firstChild); + } + + if (empty) + dom.content(row.childNodes[0], E('em', [ _('Any packet', 'nft match any traffic') ])); + + return row; + }, + + renderChain: function(data, spec) { + var title, policy, hook; + + switch (spec.type) { + case 'filter': + title = _('Traffic filter chain "%h"').format(spec.name); + break; + + case 'route': + title = _('Route action chain "%h"').format(spec.name); + break; + + case 'nat': + title = _('NAT action chain "%h"').format(spec.name); + break; + + default: + title = _('Rule container chain "%h"').format(spec.name); + break; + } + + switch (spec.policy) { + case 'drop': + policy = _('Drop unmatched packets', 'Chain policy: drop'); + break; + + default: + policy = _('Continue processing unmatched packets', 'Chain policy: accept'); + break; + } + + switch (spec.hook) { + case 'ingress': + hook = _('Capture packets directly after the NIC received them', 'Chain hook: ingress'); + break; + + case 'prerouting': + hook = _('Capture incoming packets before any routing decision', 'Chain hook: prerouting'); + break; + + case 'input': + hook = _('Capture incoming packets routed to the local system', 'Chain hook: input'); + break; + + case 'forward': + hook = _('Capture incoming packets addressed to other hosts', 'Chain hook: forward'); + break; + + case 'output': + hook = _('Capture outgoing packets originating from the local system', 'Chain hook: output'); + break; + + case 'postrouting': + hook = _('Capture outgoing packets after any routing decision', 'Chain hook: postrouting'); + break; + + default: + hook = _('Chain hook "%h"', 'Yet unknown nftables chain hook').format(spec.hook); + break; + } + + var node = E('div', { 'class': 'nft-chain' }, [ + E('h4', { + 'id': '%h.%h'.format(spec.table, spec.name) + }, [ title ]) + ]); + + if (spec.hook) { + node.appendChild(E('div', { 'class': 'nft-chain-hook' }, [ + E('ul', {}, [ + E('li', {}, _('Hook: %h (%h), Priority: %d', 'Chain hook description').format(spec.hook, hook, spec.prio)), + E('li', {}, _('Policy: %h (%h)', 'Chain hook policy').format(spec.policy, policy)) + ]) + ])); + } + + node.appendChild(E('table', { 'class': 'nft-rules table cbi-section-table' }, [ + E('tr', { 'class': 'tr table-titles' }, [ + E('th', { 'class': 'th', 'style': 'width:60%' }, [ _('Rule matches') ]), + E('th', { 'class': 'th', 'style': 'width:40%' }, [ _('Rule actions') ]) + ]) + ])); + + for (var i = 0; i < data.length; i++) + if (typeof(data[i].rule) == 'object' && data[i].rule.table == spec.table && data[i].rule.chain == spec.name) + node.lastElementChild.appendChild(this.renderRule(data, data[i].rule)); + + if (node.lastElementChild.childNodes.length == 1) + node.lastElementChild.appendChild(E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td center', 'colspan': 3 }, [ + E('em', [ _('No rules in this chain', 'nft chain is empty') ]) + ]) + ])); + + return node; + }, + + renderTable: function(data, spec) { + var title; + + switch (spec.family) { + case 'ip': + title = _('IPv4 traffic table "%h"').format(spec.name); + break; + + case 'ip6': + title = _('IPv6 traffic table "%h"').format(spec.name); + break; + + case 'inet': + title = _('IPv4/IPv6 traffic table "%h"').format(spec.name); + break; + + case 'arp': + title = _('ARP traffic table "%h"').format(spec.name); + break; + + case 'bridge': + title = _('Bridge traffic table "%h"').format(spec.name); + break; + + case 'netdev': + title = _('Network device table "%h"').format(spec.name); + break; + + default: + title = _('"%h" table "%h"', 'Yet unknown nftables table family ("family" table "name")').format(spec.family, spec.name); + break; + } + + var node = E([], [ + E('style', { 'type': 'text/css' }, [ + '.nft-rules .ifacebadge { margin: .125em }', + '.nft-rules tr > td { padding: .25em !important }', + '.nft-set, .nft-list { display: inline-block; vertical-align: middle }', + '.nft-set-items, .nft-list-items { display: inline-block; vertical-align: middle; max-width: 200px; overflow: hidden; text-overflow: ellipsis }', + '.ifacebadge.cbi-tooltip-container { cursor: help }', + '.ifacebadge.cbi-tooltip-container .cbi-tooltip { padding: .5em }' + ]), + E('div', { 'class': 'nft-table' }, [ + E('h3', [ title ]), + E('div', { 'class': 'nft-chains' }) + ]) + ]); + + for (var i = 0; i < data.length; i++) + if (typeof(data[i].chain) == 'object' && data[i].chain.table == spec.name) + node.lastElementChild.lastElementChild.appendChild(this.renderChain(data, data[i].chain)); + + return node; + }, + + render: function(data) { + var view = E('div'); + + if (!Array.isArray(data.nftables)) + return E('em', _('No nftables ruleset load')); + + for (var i = 0; i < data.nftables.length; i++) + if (data.nftables[i].hasOwnProperty('table')) + view.appendChild(this.renderTable(data.nftables, data.nftables[i].table)); + + return view; + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null +}); diff --git a/modules/luci-mod-status/root/usr/share/luci/menu.d/luci-mod-status.json b/modules/luci-mod-status/root/usr/share/luci/menu.d/luci-mod-status.json index e726c56b27..8aa58e1616 100644 --- a/modules/luci-mod-status/root/usr/share/luci/menu.d/luci-mod-status.json +++ b/modules/luci-mod-status/root/usr/share/luci/menu.d/luci-mod-status.json @@ -24,14 +24,31 @@ }, "admin/status/iptables": { - "title": "Firewall", + "title": "Firewall (iptables)", "order": 3, "action": { "type": "view", "path": "status/iptables" }, "depends": { - "acl": [ "luci-mod-status-firewall" ] + "acl": [ "luci-mod-status-firewall" ], + "fs": [ + { "/usr/sbin/iptables": "executable" }, + { "/usr/sbin/ip6tables": "executable" } + ] + } + }, + + "admin/status/nftables": { + "title": "Firewall (nftables)", + "order": 3, + "action": { + "type": "view", + "path": "status/nftables" + }, + "depends": { + "acl": [ "luci-mod-status-firewall" ], + "fs": { "/usr/sbin/nft": "executable" } } }, diff --git a/modules/luci-mod-status/root/usr/share/rpcd/acl.d/luci-mod-status.json b/modules/luci-mod-status/root/usr/share/rpcd/acl.d/luci-mod-status.json index 32de24c06f..7ad43200a3 100644 --- a/modules/luci-mod-status/root/usr/share/rpcd/acl.d/luci-mod-status.json +++ b/modules/luci-mod-status/root/usr/share/rpcd/acl.d/luci-mod-status.json @@ -71,6 +71,7 @@ "read": { "cgi-io": [ "exec" ], "file": { + "/usr/sbin/nft --json list ruleset": [ "exec" ], "/usr/sbin/iptables --line-numbers -w -nvxL -t *": [ "exec" ], "/usr/sbin/ip6tables --line-numbers -w -nvxL -t *": [ "exec" ], "/usr/sbin/ip6tables": [ "list" ] -- 2.30.2