From 64810ab2204bd1845bdabb1b11379450962d1d0d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 10 Nov 2026 00:30:33 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=8F=85=20Sync=202026-11-10=2000:30:33?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- luci-app-trafficctl/Makefile | 9 +- .../resources/view/trafficctl/rules.js | 453 ++++++++++++++++++ .../resources/view/trafficctl/status.js | 17 +- .../po/templates/trafficctl.pot | 117 +++++ .../root/usr/libexec/rpcd/luci.trafficctl | 29 +- .../luci/menu.d/luci-app-trafficctl.json | 18 +- .../share/rpcd/acl.d/luci-app-trafficctl.json | 2 +- luci-theme-vantage/Makefile | 2 +- .../htdocs/luci-static/vantage/vantage.css | 2 +- openwrt-natmap/Makefile | 4 +- openwrt-natmapt/Makefile | 4 +- 11 files changed, 638 insertions(+), 19 deletions(-) create mode 100644 luci-app-trafficctl/htdocs/luci-static/resources/view/trafficctl/rules.js diff --git a/luci-app-trafficctl/Makefile b/luci-app-trafficctl/Makefile index 3f7c18420..fe4f197fb 100644 --- a/luci-app-trafficctl/Makefile +++ b/luci-app-trafficctl/Makefile @@ -1,8 +1,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-trafficctl -PKG_VERSION:=1.29.0 -PKG_RELEASE:=25 +PKG_VERSION:=1.29.2 +PKG_RELEASE:=26 PKG_LICENSE:=Apache-2.0 PKG_MAINTAINER:=Denis Iusupov @@ -23,6 +23,11 @@ LUCI_DESCRIPTION:=Per-device traffic monitoring, rate limiting (nft/iptables), \ # configured. Measured on OpenWrt 24.10.6 with `opkg install trafficctl.ipk # luci-app-trafficctl.ipk` and with `opkg upgrade` from a feed. Keeping them # here costs nothing, since trafficctl needs them anyway. +# +# hostapd-utils is the exception: trafficctl no longer depends on it (it is +# optional there, for wired-only builds), but it stays here so the upgrade from +# 1.28.0 does not drop it and silently turn every WiFi block into a temporary +# ban. trafficctl on its own does not need it. LUCI_DEPENDS:=+trafficctl +conntrack +curl +tc +iw +hostapd-utils +luci-base +rpcd LUCI_PKGARCH:=all diff --git a/luci-app-trafficctl/htdocs/luci-static/resources/view/trafficctl/rules.js b/luci-app-trafficctl/htdocs/luci-static/resources/view/trafficctl/rules.js new file mode 100644 index 000000000..bde4f58a5 --- /dev/null +++ b/luci-app-trafficctl/htdocs/luci-static/resources/view/trafficctl/rules.js @@ -0,0 +1,453 @@ +// SPDX-License-Identifier: Apache-2.0 +'use strict'; +'require view'; +'require rpc'; +'require poll'; +'require ui'; + +// "Rules" tab (#128): every block, speed limit, shape and WiFi deny the app +// holds, whether or not the device is online. The Devices tab is built from +// live connections, so a device that went offline took its restrictions out +// of sight with it while they kept applying. The list comes from +// trafficctl-rules.sh, which reads the kernel and the saved state; edits and +// removals go through the same rpcd methods the Devices tab uses. + +(function() { + if (!document.querySelector('link[href*="trafficctl/status.css"]')) { + var lnk = document.createElement('link'); + lnk.rel = 'stylesheet'; + lnk.type = 'text/css'; + lnk.href = '/luci-static/resources/view/trafficctl/status.css'; + document.head.appendChild(lnk); + } +})(); + +var callRulesList = rpc.declare({ + object: 'luci.trafficctl', + method: 'rules_list', + expect: { result: {} } +}); + +var callBlock = rpc.declare({ + object: 'luci.trafficctl', + method: 'block', + params: ['ip', 'label'] +}); + +var callUnblock = rpc.declare({ + object: 'luci.trafficctl', + method: 'unblock', + params: ['ip', 'label'] +}); + +var callMacfilterRemove = rpc.declare({ + object: 'luci.trafficctl', + method: 'macfilter_remove', + params: ['ip', 'mac'] +}); + +var callRatelimit = rpc.declare({ + object: 'luci.trafficctl', + method: 'ratelimit', + params: ['ip', 'rate_kbit', 'label', 'mode', 'rate_kbit_up'] +}); + +var callShapeAdd = rpc.declare({ + object: 'luci.trafficctl', + method: 'shape_add', + params: ['ip', 'rate_kbit', 'label', 'rate_kbit_up'] +}); + +var callShapeRemove = rpc.declare({ + object: 'luci.trafficctl', + method: 'shape_remove', + params: ['ip', 'label'] +}); + +var TYPE_ORDER = { block: 0, wifi: 1, ratelimit: 2, shape: 3 }; + +function fmtRate(kbit) { + if (!kbit || kbit <= 0) return '—'; + var mbit = kbit / 1000; + if (mbit >= 1) return (mbit % 1 === 0 ? mbit.toFixed(0) : mbit.toFixed(1)) + ' ' + _('Mbit/s'); + return kbit + ' ' + _('kbit/s'); +} + +function fmtCeiling(kbit, kbitUp) { + if (!kbitUp || kbitUp <= 0 || kbitUp === kbit) return fmtRate(kbit); + return '↓ ' + fmtRate(kbit) + ' / ↑ ' + fmtRate(kbitUp); +} + +function isSubnet(target) { + return target.indexOf('/') >= 0 && !/\/32$/.test(target); +} + +// Sort key: addresses numerically, so a device's rules sit together and +// 192.168.1.9 comes before 192.168.1.10; MACs without an address go last. +function addrKey(r) { + var a = (r.ip || r.target || '').split('/')[0].split('.'); + if (a.length !== 4) return 'z' + (r.target || ''); + return a.map(function(o) { return ('00' + o).slice(-3); }).join('.'); +} + +function typeLabel(r) { + switch (r.type) { + case 'block': return E('span', { 'class': 'tc-badge tc-badge--blocked' }, '⛔ ' + _('Internet blocked')); + case 'wifi': return E('span', { 'class': 'tc-badge tc-badge--wifi' }, '📶 ' + _('WiFi blocked')); + case 'ratelimit': return E('span', { 'class': 'tc-badge tc-badge--limited' }, '⚡ ' + _('Speed limit')); + case 'shape': return E('span', { 'class': 'tc-badge tc-badge--shaped' }, '≋ ' + _('Shaped')); + } + return E('span', {}, r.type); +} + +function detail(r) { + if (r.type === 'ratelimit') { + var s = fmtCeiling(r.rate_kbit, r.rate_kbit_up); + if (isSubnet(r.target)) { + s += ' · ' + (r.mode === 'shared' ? _('shared between them') : _('each')); + } + return s; + } + if (r.type === 'shape') return fmtCeiling(r.rate_kbit, r.rate_kbit_up); + return ''; +} + +// What the operator needs to know about where the rule actually is. The two +// mismatches are the reason this column exists: "saved but not in effect" +// and "in effect but lost on reboot" both used to read as plain "active". +function stateCell(r, persist) { + if (r.type === 'wifi') { + switch (r.enforcement) { + case 'acl': return E('span', { 'class': 'tc-c-ok' }, '● ' + _('Active')); + case 'ban': return E('span', { 'class': 'tc-c-warn', 'title': _('hostapd-utils is missing, so hostapd only banned the device for a while. The deny list entry applies when WiFi restarts.') }, '◐ ' + _('Temporary ban only')); + case 'no-radio': return E('span', { 'class': 'tc-c-muted' }, '○ ' + _('Saved, applies when WiFi starts')); + case 'none': return E('span', { 'class': 'tc-c-err tc-fw-bold', 'title': _('The deny list names this device but the running radio does not. Install hostapd-utils, or restart WiFi (disconnects every client).') }, '✗ ' + _('Saved, not in effect')); + } + return E('span', { 'class': 'tc-c-muted' }, '? ' + _('Saved, radio state unknown')); + } + if (r.applied && r.persisted) return E('span', { 'class': 'tc-c-ok' }, '● ' + _('Active')); + if (r.applied) { + return E('span', { + 'class': 'tc-c-warn', + 'title': persist ? _('In effect now but not saved, so a reboot drops it.') + : _('Blocks and speed limits are not saved while "Keep rules after reboot" is off (Settings).') + }, '◐ ' + _('Active, lost on reboot')); + } + return E('span', { + 'class': 'tc-c-err tc-fw-bold', + 'title': _('Saved, but not in the firewall or traffic shaper right now. It is restored when the LAN interface next comes up; Apply puts it back now.') + }, '✗ ' + _('Saved, not in effect')); +} + +function mbitInput(kbit) { + return E('input', { + 'type': 'number', 'min': '0.064', 'step': '0.1', 'class': 'cbi-input-text', + 'style': 'width:6em', + 'value': kbit > 0 ? String(kbit / 1000) : '' + }); +} + +function toKbit(v) { + var n = parseFloat(v); + if (!isFinite(n) || n <= 0) return 0; + return Math.round(n * 1000); +} + +return view.extend({ + data: null, + editing: null, + filter: 'all', + query: '', + + handleSaveApply: null, + handleSave: null, + handleReset: null, + + load: function() { + return callRulesList().catch(function() { return {}; }); + }, + + refresh: function() { + var self = this; + if (self.editing) return Promise.resolve(); + return callRulesList().then(function(d) { + self.data = d || {}; + self.renderAll(); + }).catch(function() {}); + }, + + // One result handler for every action: show the backend's own message, + // which says things like "IPv4 only" or "NOT PERSISTENT" that a generic + // "done" would hide, then re-read the list. + act: function(promise, btn) { + var self = this; + if (btn) btn.disabled = true; + self.statusEl.textContent = _('Applying…'); + return promise.then(function(res) { + self.statusEl.textContent = ''; + if (res && res.ok === false) { + ui.addNotification(null, E('p', res.msg || _('Operation failed')), 'error'); + } else if (res && res.msg) { + ui.addNotification(null, E('p', res.msg), 'info'); + } + self.editing = null; + return self.refresh(); + }).catch(function(e) { + self.statusEl.textContent = ''; + if (btn) btn.disabled = false; + ui.addNotification(null, E('p', e.message), 'error'); + }); + }, + + remove: function(r, btn) { + var who = r.name ? r.name + ' (' + r.target + ')' : r.target; + if (!window.confirm(_('Remove this restriction from %s?').format(who))) return; + switch (r.type) { + case 'block': return this.act(callUnblock(r.target, ''), btn); + case 'wifi': return this.act(callMacfilterRemove(r.ip || '', r.mac || r.target), btn); + case 'ratelimit': return this.act(callRatelimit(r.target, 0, '', r.mode), btn); + case 'shape': return this.act(callShapeRemove(r.target, ''), btn); + } + }, + + // Put a saved rule back into the kernel now, with the values it was saved + // with, through the same call that created it. + reapply: function(r, btn) { + var up = (r.rate_kbit_up && r.rate_kbit_up !== r.rate_kbit) ? r.rate_kbit_up : null; + switch (r.type) { + case 'block': return this.act(callBlock(r.target, ''), btn); + case 'ratelimit': return this.act(callRatelimit(r.target, r.rate_kbit, '', r.mode, up), btn); + case 'shape': return this.act(callShapeAdd(r.target, r.rate_kbit, '', up), btn); + } + }, + + save: function(r, dn, up, mode, btn) { + var kd = toKbit(dn), ku = toKbit(up); + if (!kd) { + ui.addNotification(null, E('p', _('Enter a download rate above zero.')), 'error'); + return; + } + var sendUp = (ku && ku !== kd) ? ku : null; + if (r.type === 'ratelimit') { + return this.act(callRatelimit(r.target, kd, '', mode || r.mode, sendUp), btn); + } + return this.act(callShapeAdd(r.target, kd, '', sendUp), btn); + }, + + ruleKey: function(r) { return r.type + '|' + r.target; }, + + renderActions: function(r) { + var self = this; + var btns = []; + var small = 'font-size:11px;padding:1px 8px;margin:1px 2px'; + + if (r.type !== 'wifi' && !r.applied) { + var applyBtn = E('button', { + 'class': 'cbi-button cbi-button-apply', 'style': small, + 'title': _('Put this saved rule back into effect now') + }, _('Apply')); + applyBtn.addEventListener('click', function() { self.reapply(r, applyBtn); }); + btns.push(applyBtn); + } + if (r.type === 'ratelimit' || r.type === 'shape') { + var editBtn = E('button', { 'class': 'cbi-button cbi-button-edit', 'style': small }, _('Edit')); + editBtn.addEventListener('click', function() { + self.editing = self.ruleKey(r); + self.renderAll(); + }); + btns.push(editBtn); + } + var rmBtn = E('button', { 'class': 'cbi-button cbi-button-remove', 'style': small }, _('Remove')); + rmBtn.addEventListener('click', function() { self.remove(r, rmBtn); }); + btns.push(rmBtn); + return btns; + }, + + renderEditor: function(r) { + var self = this; + var dn = mbitInput(r.rate_kbit); + var up = mbitInput(r.rate_kbit_up); + var mode = r.mode; + var parts = [ + E('label', { 'class': 'tc-sm', 'style': 'margin-right:4px' }, '↓'), dn, + E('label', { 'class': 'tc-sm', 'style': 'margin:0 4px 0 8px' }, '↑'), up, + E('span', { 'class': 'tc-sm tc-c-muted', 'style': 'margin-left:4px' }, _('Mbit/s')) + ]; + if (r.type === 'ratelimit' && isSubnet(r.target)) { + var chips = E('span', { 'style': 'margin-left:8px' }); + ['each', 'shared'].forEach(function(m) { + var c = E('span', { + 'class': 'tc-chip' + (m === mode ? ' tc-chip--active' : ''), + 'title': m === 'shared' ? _('One bucket for the whole subnet') : _('One bucket per device inside the subnet') + }, m === 'shared' ? _('shared between them') : _('each')); + c.addEventListener('click', function() { + mode = m; + Array.prototype.forEach.call(chips.children, function(x) { x.classList.remove('tc-chip--active'); }); + c.classList.add('tc-chip--active'); + }); + chips.appendChild(c); + }); + parts.push(chips); + } + var saveBtn = E('button', { 'class': 'cbi-button cbi-button-positive', 'style': 'font-size:11px;padding:1px 8px;margin-left:8px' }, _('Save')); + saveBtn.addEventListener('click', function() { self.save(r, dn.value, up.value, mode, saveBtn); }); + var cancelBtn = E('button', { 'class': 'cbi-button', 'style': 'font-size:11px;padding:1px 8px;margin-left:2px' }, _('Cancel')); + cancelBtn.addEventListener('click', function() { self.editing = null; self.renderAll(); }); + parts.push(saveBtn, cancelBtn); + return E('div', { 'style': 'display:flex;flex-wrap:wrap;align-items:center' }, parts); + }, + + renderRow: function(r, persist) { + var target = r.target === '0.0.0.0/0' ? _('Whole network') : r.target; + var title = r.name || (isSubnet(r.target) ? _('Subnet') : (r.type === 'wifi' && !r.ip ? _('Unknown device') : '*')); + var sub = []; + if (r.type === 'wifi') { + if (r.ip) sub.push(r.ip); + } else { + sub.push(target); + if (r.mac) sub.push(r.mac); + } + if (r.type === 'wifi') sub.push(r.mac || r.target); + + var presence; + if (r.online === true) { + presence = E('span', { 'class': 'tc-c-ok', 'title': _('Has active connections or answers on the LAN') }, '● ' + _('online')); + } else if (r.online === false) { + presence = E('span', { 'class': 'tc-c-faint' }, '○ ' + _('offline')); + } else { + presence = E('span', { 'class': 'tc-c-faint' }, '—'); + } + + var editing = this.editing === this.ruleKey(r); + return E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td' }, [ + E('div', { 'class': 'tc-fw-bold' }, title), + E('div', { 'class': 'tc-mono tc-c-faint tc-sm' }, sub.join(' · ')) + ]), + E('td', { 'class': 'td tc-center tc-sm' }, presence), + E('td', { 'class': 'td' }, [ + typeLabel(r), + E('div', { 'class': 'tc-mono tc-sm', 'style': 'margin-top:2px' }, detail(r)) + ]), + E('td', { 'class': 'td tc-sm' }, stateCell(r, persist)), + E('td', { 'class': 'td' }, editing ? this.renderEditor(r) : this.renderActions(r)) + ]); + }, + + matches: function(r) { + if (this.filter === 'offline' && r.online !== false) return false; + if (this.filter !== 'all' && this.filter !== 'offline' && r.type !== this.filter) return false; + if (!this.query) return true; + var hay = [r.name, r.target, r.ip, r.mac].join(' ').toLowerCase(); + return hay.indexOf(this.query) >= 0; + }, + + renderAll: function() { + var self = this; + var d = self.data || {}; + var rules = (d.rules || []).slice(); + rules.sort(function(a, b) { + var ka = addrKey(a), kb = addrKey(b); + if (ka !== kb) return ka < kb ? -1 : 1; + return (TYPE_ORDER[a.type] || 0) - (TYPE_ORDER[b.type] || 0); + }); + + while (self.notes.firstChild) self.notes.removeChild(self.notes.firstChild); + if (d.persist_rules === false) { + self.notes.appendChild(E('div', { 'class': 'alert-message warning' }, + _('"Keep rules after reboot" is off: blocks and speed limits listed here are lost on reboot. Shapes are always kept.'))); + } + if (d.wifi_allow_ifaces > 0) { + self.notes.appendChild(E('div', { 'class': 'alert-message notice' }, + _('%d WiFi network(s) use an allow-list. A device blocked there is one missing from that list, so it cannot be shown here; manage it in Network → Wireless.').format(d.wifi_allow_ifaces))); + } + + while (self.tbody.firstChild) self.tbody.removeChild(self.tbody.firstChild); + var shown = rules.filter(function(r) { return self.matches(r); }); + if (!shown.length) { + self.tbody.appendChild(E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td tc-c-faint', 'colspan': 5 }, + rules.length ? _('No restriction matches the filter.') + : _('No device is blocked, limited or shaped.')) + ])); + } + shown.forEach(function(r) { self.tbody.appendChild(self.renderRow(r, d.persist_rules)); }); + + var off = rules.filter(function(r) { return r.online === false; }).length; + var bad = rules.filter(function(r) { return r.applied === false; }).length; + var parts = [_('%d restrictions').format(rules.length)]; + if (off) parts.push(_('%d on offline devices').format(off)); + if (bad) parts.push(_('%d not in effect').format(bad)); + self.countEl.textContent = parts.join(' · '); + }, + + renderFilters: function() { + var self = this; + var row = E('div', { 'class': 'tc-chips-row', 'style': 'margin:8px 0' }); + var opts = [ + ['all', _('All')], + ['offline', _('Offline devices')], + ['block', _('Internet blocked')], + ['wifi', _('WiFi blocked')], + ['ratelimit', _('Speed limit')], + ['shape', _('Shaped')] + ]; + opts.forEach(function(o) { + var c = E('span', { 'class': 'tc-chip' + (o[0] === self.filter ? ' tc-chip--active' : '') }, o[1]); + c.addEventListener('click', function() { + self.filter = o[0]; + Array.prototype.forEach.call(row.querySelectorAll('.tc-chip'), function(x) { x.classList.remove('tc-chip--active'); }); + c.classList.add('tc-chip--active'); + self.renderAll(); + }); + row.appendChild(c); + }); + var search = E('input', { + 'type': 'search', 'class': 'cbi-input-text', 'style': 'margin-left:8px;width:14em', + 'placeholder': _('Filter by name, IP or MAC') + }); + search.addEventListener('input', function() { + self.query = search.value.trim().toLowerCase(); + self.renderAll(); + }); + row.appendChild(search); + return row; + }, + + render: function(d) { + var self = this; + self.data = d || {}; + self.statusEl = E('span', { 'class': 'tc-c-muted', 'style': 'margin-left:10px;font-size:12px' }); + self.countEl = E('span', { 'class': 'tc-c-muted tc-sm', 'style': 'margin-left:8px' }); + self.notes = E('div', {}); + self.tbody = E('tbody', {}); + + var table = E('table', { 'class': 'table tc-table' }, [ + E('thead', {}, [ + E('tr', { 'class': 'tr cbi-section-table-titles' }, [ + E('th', { 'class': 'th' }, _('Device')), + E('th', { 'class': 'th tc-center' }, _('Presence')), + E('th', { 'class': 'th' }, _('Restriction')), + E('th', { 'class': 'th', 'title': _('Whether the rule is in the firewall or shaper now, and whether it survives a reboot') }, _('State')), + E('th', { 'class': 'th' }, _('Action')) + ]) + ]), + self.tbody + ]); + + self.renderAll(); + poll.add(function() { return self.refresh(); }, 10); + + return E('div', {}, [ + E('h2', {}, _('Active restrictions')), + E('div', { 'class': 'cbi-map-descr' }, [ + document.createTextNode(_('Every block, speed limit, shape and WiFi block the router holds, including those on devices that are offline. Each rule keeps applying while its device is away, so this is the place to find and lift it.')), + self.countEl, + self.statusEl + ]), + self.notes, + self.renderFilters(), + table + ]); + } +}); diff --git a/luci-app-trafficctl/htdocs/luci-static/resources/view/trafficctl/status.js b/luci-app-trafficctl/htdocs/luci-static/resources/view/trafficctl/status.js index 8d5921442..0e7fe9c02 100644 --- a/luci-app-trafficctl/htdocs/luci-static/resources/view/trafficctl/status.js +++ b/luci-app-trafficctl/htdocs/luci-static/resources/view/trafficctl/status.js @@ -2264,10 +2264,21 @@ return view.extend({ } updateInetBtn(false); - function updateWifiBtn(wifiBlocked, hasMac) { - if (!hasMac) { wifiBtn.classList.add('tc-hidden'); return; } + // filter is the backend's wifi_filter: "none" on a router without any + // wifi-iface (wired-only builds), "temporary" where the optional + // hostapd-utils is missing and a block is only a timed hostapd ban, + // "full" otherwise. Older backends send nothing, read as "full". An + // existing block is always offered for removal, whatever the router + // can do now. + function updateWifiBtn(wifiBlocked, hasMac, filter) { + if (!hasMac || (filter === 'none' && !wifiBlocked)) { wifiBtn.classList.add('tc-hidden'); return; } wifiBtn.classList.remove('tc-hidden'); wifiBtn.disabled = false; + if (filter === 'temporary') { + wifiBtn.setAttribute('data-tip', _('hostapd-utils is not installed: a WiFi block lasts one hour and does not reach the running radio ACL. Install hostapd-utils for permanent blocks.')); + } else { + wifiBtn.removeAttribute('data-tip'); + } if (wifiBlocked) { wifiBtn.textContent = _('Unblock WiFi'); wifiBtn.className = 'cbi-button cbi-button-positive'; @@ -3182,7 +3193,7 @@ return view.extend({ } updateInetBtn(data.blocked); - updateWifiBtn(data.wifi_blocked, !!data.mac); + updateWifiBtn(data.wifi_blocked, !!data.mac, data.wifi_filter); var curShapeRate = data.shape_kbit || 0; var curLimitRate = data.rate_limit_kbit || 0; diff --git a/luci-app-trafficctl/po/templates/trafficctl.pot b/luci-app-trafficctl/po/templates/trafficctl.pot index b56d634cc..b7e34ac10 100644 --- a/luci-app-trafficctl/po/templates/trafficctl.pot +++ b/luci-app-trafficctl/po/templates/trafficctl.pot @@ -1,18 +1,33 @@ msgid "" msgstr "Content-Type: text/plain; charset=UTF-8" +msgid "\"Keep rules after reboot\" is off: blocks and speed limits listed here are lost on reboot. Shapes are always kept." +msgstr "" + msgid "\"all\" for every device, or a CIDR such as 10.0.20.0/24" msgstr "" +msgid "%d WiFi network(s) use an allow-list. A device blocked there is one missing from that list, so it cannot be shown here; manage it in Network → Wireless." +msgstr "" + msgid "%d not applied" msgstr "" +msgid "%d not in effect" +msgstr "" + +msgid "%d on offline devices" +msgstr "" + msgid "%d packets dropped (%s)" msgstr "" msgid "%d pkts, %s dropped" msgstr "" +msgid "%d restrictions" +msgstr "" + msgid "%s down / %s up" msgstr "" @@ -64,6 +79,9 @@ msgstr "" msgid "Action" msgstr "" +msgid "Active" +msgstr "" + msgid "Active connections in conntrack" msgstr "" @@ -73,9 +91,15 @@ msgstr "" msgid "Active inbound connections" msgstr "" +msgid "Active restrictions" +msgstr "" + msgid "Active subnet limits" msgstr "" +msgid "Active, lost on reboot" +msgstr "" + msgid "Active:" msgstr "" @@ -145,6 +169,9 @@ msgstr "" msgid "Blocks" msgstr "" +msgid "Blocks and speed limits are not saved while \"Keep rules after reboot\" is off (Settings)." +msgstr "" + msgid "Bot commands:" msgstr "" @@ -346,12 +373,21 @@ msgstr "" msgid "EWMA" msgstr "" +msgid "Edit" +msgstr "" + msgid "Enabled" msgstr "" msgid "Enter a custom speed value" msgstr "" +msgid "Enter a download rate above zero." +msgstr "" + +msgid "Every block, speed limit, shape and WiFi block the router holds, including those on devices that are offline. Each rule keeps applying while its device is away, so this is the place to find and lift it." +msgstr "" + msgid "Every device in" msgstr "" @@ -382,6 +418,9 @@ msgstr "" msgid "External (WAN) port" msgstr "" +msgid "Filter by name, IP or MAC" +msgstr "" + msgid "Filter connections by protocol" msgstr "" @@ -454,6 +493,9 @@ msgstr "" msgid "Hardware offload" msgstr "" +msgid "Has active connections or answers on the LAN" +msgstr "" + msgid "History" msgstr "" @@ -487,6 +529,9 @@ msgstr "" msgid "In" msgstr "" +msgid "In effect now but not saved, so a reboot drops it." +msgstr "" + msgid "In this mode the kernel's conntrack, firewall, and tc are bypassed for offloaded flows:" msgstr "" @@ -511,6 +556,9 @@ msgstr "" msgid "Internet access status (paused = traffic blocked)" msgstr "" +msgid "Internet blocked" +msgstr "" + msgid "Internet is cut for all devices" msgstr "" @@ -637,6 +685,9 @@ msgstr "" msgid "No baseline recorded yet. It is taken from the current leases when you switch this on." msgstr "" +msgid "No device is blocked, limited or shaped." +msgstr "" + msgid "No devices match filter." msgstr "" @@ -655,6 +706,9 @@ msgstr "" msgid "No port forwards or WAN-open ports are configured in the firewall." msgstr "" +msgid "No restriction matches the filter." +msgstr "" + msgid "No traffic has been recorded for this period yet. The first figures appear after the next flush." msgstr "" @@ -685,6 +739,9 @@ msgstr "" msgid "Off." msgstr "" +msgid "Offline devices" +msgstr "" + msgid "Offloads routing to the hardware engine (PPE/NPU). Requires software offload. ⚠ On many platforms (e.g. Mediatek Filogic) the driver does not report byte counters back to the kernel — real-time speed monitoring will show zero." msgstr "" @@ -781,6 +838,9 @@ msgstr "" msgid "Port Forwards & Open Ports" msgstr "" +msgid "Presence" +msgstr "" + msgid "Preview:" msgstr "" @@ -790,6 +850,9 @@ msgstr "" msgid "Protocol" msgstr "" +msgid "Put this saved rule back into effect now" +msgstr "" + msgid "Qualcomm NSS acceleration is active" msgstr "" @@ -838,6 +901,9 @@ msgstr "" msgid "Remove limit" msgstr "" +msgid "Remove this restriction from %s?" +msgstr "" + msgid "Removing from" msgstr "" @@ -865,12 +931,18 @@ msgstr "" msgid "Restoring…" msgstr "" +msgid "Restriction" +msgstr "" + msgid "Resume" msgstr "" msgid "Routed via %s" msgstr "" +msgid "Rules" +msgstr "" + msgid "Sampled. Byte counts come from conntrack, which only reports flows that are alive when it is read — a connection that both starts and finishes between two samples is not counted. Shorten the sampling interval to narrow that gap." msgstr "" @@ -880,6 +952,18 @@ msgstr "" msgid "Save as router default" msgstr "" +msgid "Saved, applies when WiFi starts" +msgstr "" + +msgid "Saved, but not in the firewall or traffic shaper right now. It is restored when the LAN interface next comes up; Apply puts it back now." +msgstr "" + +msgid "Saved, not in effect" +msgstr "" + +msgid "Saved, radio state unknown" +msgstr "" + msgid "Saving…" msgstr "" @@ -904,6 +988,9 @@ msgstr "" msgid "Settings" msgstr "" +msgid "Shaped" +msgstr "" + msgid "Shaped devices" msgstr "" @@ -970,6 +1057,9 @@ msgstr "" msgid "Speed is measured via nftables counters installed at a higher priority (before the flowtable), so graphs are accurate." msgstr "" +msgid "Speed limit" +msgstr "" + msgid "Speed limit: ⚡ %s" msgstr "" @@ -997,6 +1087,9 @@ msgstr "" msgid "Store the current Poll and Window as this router's defaults, for browsers that have not set their own" msgstr "" +msgid "Subnet" +msgstr "" + msgid "Switch it off" msgstr "" @@ -1030,6 +1123,9 @@ msgstr "" msgid "Telegram, logging, new-device defaults and flow offload moved to the Settings tab." msgstr "" +msgid "Temporary ban only" +msgstr "" + msgid "Test" msgstr "" @@ -1042,6 +1138,9 @@ msgstr "" msgid "The auto-revert helper is not running; this page re-checks the deadline while it is open." msgstr "" +msgid "The deny list names this device but the running radio does not. Install hostapd-utils, or restart WiFi (disconnects every client)." +msgstr "" + msgid "The flowtable" msgstr "" @@ -1141,6 +1240,9 @@ msgstr "" msgid "Unblock WiFi" msgstr "" +msgid "Unknown device" +msgstr "" + msgid "Unknown error" msgstr "" @@ -1177,6 +1279,9 @@ msgstr "" msgid "When this device last moved any traffic, not when it was last connected" msgstr "" +msgid "Whether the rule is in the firewall or shaper now, and whether it survives a reboot" +msgstr "" + msgid "Which link carried the traffic — the question the device table cannot answer. This is a separate series, not a breakdown of it: the rows include the router's own traffic and broadcast, and they overlap each other (a VPN tunnel's bytes also travelled over its WAN), so the two tables are not meant to add up. Received and Sent are relative to the interface, so on the LAN bridge Received is what your devices sent." msgstr "" @@ -1321,6 +1426,12 @@ msgstr "" msgid "h ago" msgstr "" +msgid "hostapd-utils is missing, so hostapd only banned the device for a while. The deny list entry applies when WiFi restarts." +msgstr "" + +msgid "hostapd-utils is not installed: a WiFi block lasts one hour and does not reach the running radio ACL. Install hostapd-utils for permanent blocks." +msgstr "" + msgid "is not one of the subnets this router monitors, so a limit on it would be accepted but never enforced. A firewall zone other than \"lan\" with masq=1 (the usual guest-VLAN setup) is excluded. Check with: uci show firewall | grep -E \"name=|masq=\"" msgstr "" @@ -1363,12 +1474,18 @@ msgstr "" msgid "of its own." msgstr "" +msgid "offline" +msgstr "" + msgid "old" msgstr "" msgid "on the deny list but still connected; install hostapd-utils or restart WiFi" msgstr "" +msgid "online" +msgstr "" + msgid "open port" msgstr "" diff --git a/luci-app-trafficctl/root/usr/libexec/rpcd/luci.trafficctl b/luci-app-trafficctl/root/usr/libexec/rpcd/luci.trafficctl index c0e50017c..a79a29889 100755 --- a/luci-app-trafficctl/root/usr/libexec/rpcd/luci.trafficctl +++ b/luci-app-trafficctl/root/usr/libexec/rpcd/luci.trafficctl @@ -43,7 +43,7 @@ list) printf '"block":{"ip":"str","label":"str"},' printf '"unblock":{"ip":"str","label":"str"},' printf '"macfilter_add":{"ip":"str"},' - printf '"macfilter_remove":{"ip":"str"},' + printf '"macfilter_remove":{"ip":"str","mac":"str"},' printf '"ratelimit":{"ip":"str","rate_kbit":"int","label":"str","mode":"str","rate_kbit_up":"int"},' printf '"ratelimit_stats":{},' printf '"subnets":{},' @@ -51,6 +51,7 @@ list) printf '"shape_remove":{"ip":"str","label":"str"},' printf '"shape_status":{"ip":"str"},' printf '"shape_stats":{},' + printf '"rules_list":{},' printf '"rdns":{"ip":"str"},' printf '"netify_status":{},' printf '"netify_list":{},' @@ -154,10 +155,14 @@ call) macfilter_remove) read -r input ip=$(echo "$input" | jsonfilter -e '@.ip' 2>/dev/null) - if [ -z "$ip" ]; then - echo '{"ok":false,"msg":"ip required"}' + # The deny list is keyed on the MAC, and a device that has been offline + # long enough has no lease left to find it by IP. The Rules tab sends + # the MAC it read from the list; the script validates either form. + mac=$(echo "$input" | jsonfilter -e '@.mac' 2>/dev/null) + if [ -z "$ip" ] && [ -z "$mac" ]; then + echo '{"ok":false,"msg":"ip or mac required"}' else - "$SCRIPTS/trafficctl-macfilter-remove.sh" "$ip" + "$SCRIPTS/trafficctl-macfilter-remove.sh" "${mac:-$ip}" fi ;; ratelimit) @@ -227,6 +232,13 @@ call) "$SCRIPTS/trafficctl-shape-stats.sh" printf '}' ;; + rules_list) + # Every block, limit, shape and WiFi deny, online or not (#128). Read + # only: the Rules tab edits and removes through the methods above. + printf '{"result":' + "$SCRIPTS/trafficctl-rules.sh" list + printf '}' + ;; rdns) read -r input ip=$(echo "$input" | jsonfilter -e '@.ip' 2>/dev/null) @@ -429,6 +441,15 @@ call) done tctl_commit tctl_log "telegram_config_change" "" "" "luci" "${TCTL_SRC:-local}" + # The init script starts the bot only while the flag is set, but boot + # runs only what rc.d enables. A reload alone left a bot switched on + # here running until the next reboot and then gone; rc.d follows the + # flag so both agree. + if [ "$(uci -q get trafficctl.telegram.enabled)" = "1" ]; then + /etc/init.d/trafficctl-telegram enable 2>/dev/null + else + /etc/init.d/trafficctl-telegram disable 2>/dev/null + fi /etc/init.d/trafficctl-telegram reload 2>/dev/null & echo '{"ok":true}' ;; diff --git a/luci-app-trafficctl/root/usr/share/luci/menu.d/luci-app-trafficctl.json b/luci-app-trafficctl/root/usr/share/luci/menu.d/luci-app-trafficctl.json index 6ed0ae40f..40ecef5d8 100644 --- a/luci-app-trafficctl/root/usr/share/luci/menu.d/luci-app-trafficctl.json +++ b/luci-app-trafficctl/root/usr/share/luci/menu.d/luci-app-trafficctl.json @@ -23,9 +23,21 @@ } }, + "admin/status/trafficctl/rules": { + "title": "Rules", + "order": 2, + "action": { + "type": "view", + "path": "trafficctl/rules" + }, + "depends": { + "acl": [ "luci-app-trafficctl" ] + } + }, + "admin/status/trafficctl/history": { "title": "History", - "order": 2, + "order": 3, "action": { "type": "view", "path": "trafficctl/history" @@ -37,7 +49,7 @@ "admin/status/trafficctl/portfw": { "title": "Port Forwards", - "order": 3, + "order": 4, "action": { "type": "view", "path": "trafficctl/portfw" @@ -48,7 +60,7 @@ }, "admin/status/trafficctl/settings": { "title": "Settings", - "order": 4, + "order": 5, "action": { "type": "view", "path": "trafficctl/settings" diff --git a/luci-app-trafficctl/root/usr/share/rpcd/acl.d/luci-app-trafficctl.json b/luci-app-trafficctl/root/usr/share/rpcd/acl.d/luci-app-trafficctl.json index 434445776..c71399438 100644 --- a/luci-app-trafficctl/root/usr/share/rpcd/acl.d/luci-app-trafficctl.json +++ b/luci-app-trafficctl/root/usr/share/rpcd/acl.d/luci-app-trafficctl.json @@ -8,7 +8,7 @@ "summary", "device", "bytes", "ifaces", "ratelimit_stats", "shape_stats", "rdns", "subnets", "portfw_list", "names_list", "netify_status", "netify_list", - "shape_status", + "shape_status", "rules_list", "config_get", "telegram_config_get", "logging_config_get", "newdevice_config_get", "history_status", "history_read", "history_read_ifaces", "history_periods", diff --git a/luci-theme-vantage/Makefile b/luci-theme-vantage/Makefile index b2706f4c4..0b068051c 100644 --- a/luci-theme-vantage/Makefile +++ b/luci-theme-vantage/Makefile @@ -11,7 +11,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-theme-vantage PKG_VERSION:=1.0.17 -PKG_RELEASE:=38 +PKG_RELEASE:=39 PKG_LICENSE:=GPL-3.0-or-later LUCI_TITLE:=Vantage theme diff --git a/luci-theme-vantage/htdocs/luci-static/vantage/vantage.css b/luci-theme-vantage/htdocs/luci-static/vantage/vantage.css index 182e30a72..6c904b705 100644 --- a/luci-theme-vantage/htdocs/luci-static/vantage/vantage.css +++ b/luci-theme-vantage/htdocs/luci-static/vantage/vantage.css @@ -635,7 +635,7 @@ a.vt-crumb:hover { color: var(--text); } /* where the picks are kept, when not for every browser */ .vt-appear-note { max-width: 32ch; margin: 0; color: var(--text-3); font-size: var(--fs-sm); line-height: 1.4; } .vt-appear-note[hidden] { display: none; } -.vt-appear-ver { margin: 0; color: var(--text-3); font-size: var(--fs-micro); } +.vt-appear-ver { margin: 0; color: var(--text-3); font-size: var(--fs-micro); display:none; } .vt-appear-num { font-family: var(--mono); } .vt-appear-check { margin: 0; padding: 0; border: 0; background: none; color: var(--accent-text); font: inherit; cursor: pointer; text-decoration: underline; text-underline-offset: 2px; } .vt-appear-check[aria-disabled="true"] { color: var(--text-3); cursor: default; text-decoration: none; } diff --git a/openwrt-natmap/Makefile b/openwrt-natmap/Makefile index 0a34c4974..5b3b0a33c 100644 --- a/openwrt-natmap/Makefile +++ b/openwrt-natmap/Makefile @@ -1,8 +1,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=natmap -PKG_VERSION:=20260214 -PKG_RELEASE:=1 +PKG_VERSION:=20261109 +PKG_RELEASE:=2 PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.xz PKG_SOURCE_URL:=https://github.com/heiher/natmap/releases/download/$(PKG_VERSION) diff --git a/openwrt-natmapt/Makefile b/openwrt-natmapt/Makefile index a287ae706..78fa38dde 100644 --- a/openwrt-natmapt/Makefile +++ b/openwrt-natmapt/Makefile @@ -9,7 +9,7 @@ PKG_NAME:=natmapt PKG_UPSTREAM_VERSION:=20260214 PKG_UPSTREAM_GITHASH:= PKG_VERSION:=$(PKG_UPSTREAM_VERSION)$(if $(PKG_UPSTREAM_GITHASH),~$(call version_abbrev,$(PKG_UPSTREAM_GITHASH))) -PKG_RELEASE:=18 +PKG_RELEASE:=19 SCRIPTS_VERSION:=0.2026.01.24 PKG_SOURCE_SUBDIR:=$(PKG_UPSTREAM_NAME)-$(PKG_UPSTREAM_VERSION) @@ -23,7 +23,7 @@ PKG_SOURCE:=$(PKG_SOURCE_SUBDIR).tar.xz else PKG_SOURCE_PROTO:=git PKG_SOURCE_URL:=https://github.com/heiher/natmap.git -PKG_SOURCE_VERSION:=87b674e2e00e6478824d835e2e2af68a99085e46 +PKG_SOURCE_VERSION:=b7c09eb633d788438a5040a072a562afcd9e6caa PKG_MIRROR_HASH:=skip PKG_SOURCE:=$(PKG_SOURCE_SUBDIR)-$(PKG_SOURCE_VERSION).tar.gz