'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': _('Egress 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': _('Packet mark', 'nft meta mark'), 'meta.time': _('Packet receive time', 'nft meta time'), 'meta.hour': _('Current time', 'nft meta hour'), 'meta.day': _('Current weekday', 'nft meta day'), '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': _('ICMP code', 'nft icmp code'), 'icmp.type': _('ICMP type', 'nft icmp 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'), 'th.sport': _('Transport header source port', 'nft th sport'), 'th.dport': _('Transport header destination port', 'nft th dport'), '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'), 'payload.ll': _('Link layer header bits %d-%d', 'nft @ll,off,len'), 'payload.nh': _('Network header bits %d-%d', 'nft @nh,off,len'), 'payload.th': _('Transport header bits %d-%d', 'nft @th,off,len') }; 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'), 'log': _('Log event "%h…"', 'nft log 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 Promise.all([ L.resolveDefault(fs.exec_direct('/usr/sbin/nft', [ '--terse', '--json', 'list', 'ruleset' ], 'json'), {}), L.resolveDefault(fs.exec_direct('/usr/sbin/iptables-save'), ''), L.resolveDefault(fs.exec_direct('/usr/sbin/ip6tables-save'), '') ]); }, 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': case 'log': 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 '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 'payload': if (spec.protocol && spec.field) { var k = '%h.%h'.format(spec.protocol, spec.field); return expr_translations[k] || '%s'.format(k); } else if (spec.base && spec.offset != null && spec.len != null) { var k = 'payload.%h'.format(spec.base); return (expr_translations[k] || '@%s,%%d,%%d'.format(spec.base)).format(spec.offset + 1, spec.offset + spec.len + 1); } return 'payload: %s'.format(kind, JSON.stringify(spec)); 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' }, (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.flowtable.replace(/^@/, ''))); case 'log': return E('span', { 'class': 'ifacebadge' }, action_translations.log.format(spec.prefix)); 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: { op: "add", flowtable: 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; }, checkLegacyRules: function(ipt4save, ipt6save) { if (ipt4save.match(/\n-A /) || ipt6save.match(/\n-A /)) { ui.addNotification(_('Legacy rules detected'), [ E('p', _('There are legacy iptables rules present on the system. Mixing iptables and nftables rules is discouraged and may lead to incomplete traffic filtering.')), E('button', { 'class': 'btn cbi-button', 'click': function() { location.href = 'nftables/iptables' } }, _('Open iptables rules overview…')) ], 'warning'); } }, render: function(data) { var view = E('div'), nft = data[0], ipt = data[1], ipt6 = data[2]; this.checkLegacyRules(ipt, ipt6); if (!Array.isArray(nft.nftables)) return E('em', _('No nftables ruleset loaded.')); for (var i = 0; i < nft.nftables.length; i++) if (nft.nftables[i].hasOwnProperty('table')) view.appendChild(this.renderTable(nft.nftables, nft.nftables[i].table)); return view; }, handleSaveApply: null, handleSave: null, handleReset: null });