luci-mod-status: add nftables status page 5652/head
authorJo-Philipp Wich <jo@mein.io>
Fri, 21 Jan 2022 19:22:22 +0000 (20:22 +0100)
committerJo-Philipp Wich <jo@mein.io>
Mon, 7 Feb 2022 12:24:33 +0000 (13:24 +0100)
Signed-off-by: Jo-Philipp Wich <jo@mein.io>
modules/luci-mod-status/htdocs/luci-static/resources/view/status/nftables.js [new file with mode: 0644]
modules/luci-mod-status/root/usr/share/luci/menu.d/luci-mod-status.json
modules/luci-mod-status/root/usr/share/rpcd/acl.d/luci-mod-status.json

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 (file)
index 0000000..c40ec83
--- /dev/null
@@ -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 = {
+       '==': _('<var>%s</var> is <strong>%s</strong>', 'nft relational "==" operator expression'),
+       '!=': _('<var>%s</var> not <strong>%s</strong>', 'nft relational "!=" operator expression'),
+       '>=': _('<var>%s</var> greater than or equal to <strong>%s</strong>', 'nft relational ">=" operator expression'),
+       '<=': _('<var>%s</var> lower than or equal to <strong>%s</strong>', 'nft relational "<=" operator expression'),
+       '>': _('<var>%s</var> greater than <strong>%s</strong>', 'nft relational ">" operator expression'),
+       '<': _('<var>%s</var> lower than <strong>%s</strong>', 'nft relational "<" operator expression'),
+       'in': _('<var>%s</var> is one of <strong>%s</strong>', 'nft relational "in" operator expression'),
+       'in_set': _('<var>%s</var> in set <strong>%s</strong>', 'nft set match expression'),
+       'not_in_set': _('<var>%s</var> not in set <strong>%s</strong>', 'nft not in set match expression'),
+};
+
+var action_translations = {
+       'accept': _('Accept packet', 'nft accept action'),
+       'drop': _('Drop packet', 'nft drop action'),
+       'jump': _('Continue in <strong><a href="#%q.%q">%h</a></strong>', 'nft jump action'),
+
+       'reject.tcp reset': _('Reject packet with <strong>TCP reset</strong>', 'nft reject with tcp reset'),
+       'reject.icmp': _('Reject IPv4 packet with <strong>ICMP type %h</strong>', 'nft reject with icmp type'),
+       'reject.icmpv6': _('Reject packet with <strong>ICMPv6 type %h</strong>', 'nft reject with icmpv6 type'),
+       'reject.icmpx': _('Reject packet with <strong>ICMP type %h</strong>', 'nft reject with icmpx type'),
+
+       'snat.ip.addr': _('Rewrite source to <strong>%h</strong>', 'nft snat ip to addr'),
+       'snat.ip.addr.port': _('Rewrite source to <strong>%h</strong>, port <strong>%h</strong>', 'nft snat ip to addr:port'),
+
+       'snat.ip6.addr': _('Rewrite source to <strong>%h</strong>', 'nft snat ip6 to addr'),
+       'snat.ip6.addr.port': _('Rewrite source to <strong>%h</strong>, port <strong>%h</strong>', 'nft snat ip6 to addr:port'),
+
+       'dnat.ip.addr': _('Rewrite destination to <strong>%h</strong>', 'nft dnat ip to addr'),
+       'dnat.ip.addr.port': _('Rewrite destination to <strong>%h</strong>, port <strong>%h</strong>', 'nft dnat ip to addr:port'),
+
+       'dnat.ip6.addr': _('Rewrite destination to <strong>%h</strong>', 'nft dnat ip6 to addr'),
+       'dnat.ip6.addr.port': _('Rewrite destination to <strong>%h</strong>, port <strong>%h</strong>', 'nft dnat ip6 to addr:port'),
+
+       'redirect': _('Redirect to local system', 'nft redirect'),
+       'redirect.port': _('Redirect to local port <strong>%h</strong>', 'nft redirect to port'),
+
+       'masquerade': _('Rewrite to egress device address'),
+
+       'mangle': _('Set header field <var>%s</var> to <strong>%s</strong>', 'nft mangle'),
+
+       'limit': _('At most <strong>%h</strong> per <strong>%h</strong>, burst of <strong>%h</strong>'),
+       'limit.burst': _('At most <strong>%h</strong> per <strong>%h</strong>, burst of <strong>%h</strong>'),
+       'limit.inv': _('At least <strong>%h</strong> per <strong>%h</strong>, burst of <strong>%h</strong>'),
+       'limit.inv.burst': _('At least <strong>%h</strong> per <strong>%h</strong>, burst of <strong>%h</strong>'),
+
+       'return': _('Continue in calling chain'),
+
+       'flow': _('Utilize flow table <strong>%h</strong>')
+};
+
+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('<span class="nft-set-item">%s</span>'.format(this.exprToString(spec[i])));
+                               lis.push('<span class="ifacebadge">%s</span>'.format(this.exprToString(spec[i])));
+                       }
+
+                       var tpl;
+
+                       if (kind == 'set')
+                               tpl = '<div class="nft-set cbi-tooltip-container">{ <span class="nft-set-items">%s</span> }<div class="cbi-tooltip">%s</div></div>';
+                       else
+                               tpl = '<div class="nft-list cbi-tooltip-container"><span class="nft-list-items">%s</span><div class="cbi-tooltip">%s</div></div>';
+
+                       return tpl.format(items.join(', '), lis.join('<br />'));
+
+               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] || '<em>%s</em>'.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] || '<var>%%s</var> %h <strong>%%s</strong>'.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('<br />'))
+               ]) : 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] || '<em>%h</em>'.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: <strong>%h</strong> (%h), Priority: <strong>%d</strong>', 'Chain hook description').format(spec.hook, hook, spec.prio)),
+                                       E('li', {}, _('Policy: <strong>%h</strong> (%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
+});
index e726c56b2754d85d2b136a64238cfb46ed392836..8aa58e161652fd6524539fd1618f488e1a70ca12 100644 (file)
        },
 
        "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" }
                }
        },
 
index 32de24c06f34c343ac499df1937f064da657c8fb..7ad43200a374a8f47a43a4bbf4a0ced00b8a8cab 100644 (file)
@@ -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" ]