From b7ebf9532e73629cf306460828ab29dd7cb6c5be Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 30 Oct 2026 21:24:21 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A5=20Sync=202026-10-30=2021:24:21?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- luci-mod-status/Makefile | 2 +- .../root/usr/lib/lua/luci/netstat.lua | 239 ++++++++++++++++++ 2 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 luci-mod-status/root/usr/lib/lua/luci/netstat.lua diff --git a/luci-mod-status/Makefile b/luci-mod-status/Makefile index 4957fb807..ac7dbf0fe 100644 --- a/luci-mod-status/Makefile +++ b/luci-mod-status/Makefile @@ -9,7 +9,7 @@ include $(TOPDIR)/rules.mk LUCI_TITLE:=LuCI Status Pages LUCI_DEPENDS:=+luci-base +luci-app-oui +luci-mod-dashboard +libiwinfo +rpcd-mod-iwinfo +rpcd-mod-rrdns +ucode +ucode-mod-fs +ucode-mod-ubus +ucode-mod-socket -PKG_RELEASE:=38 +PKG_RELEASE:=39 PKG_BUILD_DEPENDS:=iwinfo PKG_LICENSE:=Apache-2.0 diff --git a/luci-mod-status/root/usr/lib/lua/luci/netstat.lua b/luci-mod-status/root/usr/lib/lua/luci/netstat.lua new file mode 100644 index 000000000..ef508ee17 --- /dev/null +++ b/luci-mod-status/root/usr/lib/lua/luci/netstat.lua @@ -0,0 +1,239 @@ +-- Shared helpers for luci-app-netstat. +-- +-- Everything here relies only on what every OpenWrt router has: the vnStat 2 +-- CLI, procfs/sysfs and netifd over ubus. There are no board-specific +-- interface names or device paths; the uplink is found from the routing +-- table and history comes from whatever vnStat has in its database. + +local nixio = require "nixio" +local fs = require "nixio.fs" +local util = require "luci.util" +local jsonc = require "luci.jsonc" +local uci = require "luci.model.uci" + +local M = {} + +M.VNSTAT = "/usr/bin/vnstat" + +-- Netdev and logical interface names are used in shell commands, so only +-- allow the characters Linux and netifd actually use. +function M.safe_name(name) + return type(name) == "string" and name ~= "" and name:match("^[%w%-%._]+$") ~= nil +end + +function M.netdev_exists(name) + return M.safe_name(name) and fs.access("/sys/class/net/" .. name) and true or false +end + +function M.vnstat_installed() + return fs.access(M.VNSTAT, "x") and true or false +end + +-- Interfaces present in the vnStat database, one per line from +-- "vnstat --dbiflist 1". Returns a sorted list and a lookup set. +function M.db_interfaces() + local list, set = {}, {} + if not M.vnstat_installed() then return list, set end + local out = util.exec(M.VNSTAT .. " --dbiflist 1 2>/dev/null") or "" + for name in out:gmatch("[^\n]+") do + name = name:match("^%s*(.-)%s*$") + if M.safe_name(name) and not set[name] then + set[name] = true + list[#list + 1] = name + end + end + table.sort(list) + return list, set +end + +-- Run "vnstat --json [-i iface]" and return the raw text if it +-- looks like JSON, or nil (vnStat missing, interface not in the database, ...). +function M.vnstat_json_raw(mode, limit, iface) + if not M.vnstat_installed() then return nil end + local cmd = M.VNSTAT .. " --json " .. mode .. " " .. tostring(tonumber(limit) or 0) + if iface then + if not M.safe_name(iface) then return nil end + cmd = cmd .. " -i " .. iface + end + local out = util.exec(cmd .. " 2>/dev/null") or "" + if not out:match("^%s*{") then return nil end + return out +end + +-- Devices that carry a default route, lowest metric first (IPv4, then IPv6). +-- Read straight from the kernel so it works whatever the uplink is called +-- (pppoe-wan, wwan0, eth1, wg0, ...). +function M.default_route_devices() + local found, seen = {}, {} + local function add(dev, metric) + if dev and dev ~= "lo" and not seen[dev] then + seen[dev] = true + found[#found + 1] = { dev = dev, metric = metric or 0 } + end + end + + local f = io.open("/proc/net/route", "r") + if f then + local v4 = {} + for line in f:lines() do + local fld = {} + for w in line:gmatch("%S+") do fld[#fld + 1] = w end + -- Iface Destination Gateway Flags RefCnt Use Metric Mask ... + if fld[2] == "00000000" and fld[8] == "00000000" then + v4[#v4 + 1] = { dev = fld[1], metric = tonumber(fld[7]) or 0 } + end + end + f:close() + table.sort(v4, function(a, b) return a.metric < b.metric end) + for _, r in ipairs(v4) do add(r.dev, r.metric) end + end + + f = io.open("/proc/net/ipv6_route", "r") + if f then + local v6 = {} + for line in f:lines() do + local fld = {} + for w in line:gmatch("%S+") do fld[#fld + 1] = w end + -- dest prefixlen src srcprefixlen nexthop metric refcnt use flags dev + if fld[1] == "00000000000000000000000000000000" and fld[2] == "00" and fld[10] then + v6[#v6 + 1] = { dev = fld[10], metric = tonumber(fld[6], 16) or 0 } + end + end + f:close() + table.sort(v6, function(a, b) return a.metric < b.metric end) + for _, r in ipairs(v6) do add(r.dev, r.metric) end + end + + local devs = {} + for _, r in ipairs(found) do devs[#devs + 1] = r.dev end + return devs +end + +-- With a "known" set, a device is only accepted if it is in that set (e.g. +-- the vnStat database); otherwise it has to exist in /sys/class/net. +local function accept(dev, known) + if not M.safe_name(dev) then return nil end + if known then return known[dev] and dev or nil end + return M.netdev_exists(dev) and dev or nil +end + +-- Accepts a logical netifd interface name ("wan", "modem") or a netdev name +-- ("eth1", "wwan0") and returns the matching netdev, or nil. Logical names +-- win: on DSA routers the WAN port netdev is often also called "wan", but +-- the logical interface's L3 device (e.g. pppoe-wan) is the real uplink. +function M.resolve_device(name, known) + if not M.safe_name(name) then return nil end + local st = util.ubus("network.interface." .. name, "status", {}) + if type(st) == "table" then + local dev = accept(st.l3_device, known) or accept(st.device, known) + if dev then return dev end + end + return accept(name, known) +end + +function M.preferred_name() + local c = uci.cursor() + return c:get_first("netstats", "config", "prefer") or "" +end + +-- The device to show: the "Preferred Interface" setting if it resolves, +-- otherwise the default-route device. +function M.wan_device(known) + local dev = M.resolve_device(M.preferred_name(), known) + if dev then return dev end + for _, d in ipairs(M.default_route_devices()) do + dev = accept(d, known) + if dev then return dev end + end + return nil +end + +local function first_addr(list) + if type(list) == "table" and type(list[1]) == "table" then + return list[1].address + end + return nil +end + +-- Local address of a device: netifd's view first, then the kernel's. +function M.device_ipaddr(dev) + if not M.safe_name(dev) then return nil end + + local dump = util.ubus("network.interface", "dump", {}) + if type(dump) == "table" and type(dump.interface) == "table" then + for _, key in ipairs({ "ipv4-address", "ipv6-address" }) do + for _, ifc in ipairs(dump.interface) do + if ifc.up and ifc.l3_device == dev then + local a = first_addr(ifc[key]) + if a then return a end + end + end + end + end + + local addrs = nixio.getifaddrs() or {} + for _, fam in ipairs({ "inet", "inet6" }) do + for _, a in ipairs(addrs) do + if a.name == dev and a.family == fam and a.addr + and not a.addr:match("^[Ff][Ee]80:") then + return a.addr + end + end + end + return nil +end + +-- This month's rx/tx bytes for a device from the vnStat database, cached in +-- /tmp for a minute because the status widget polls every two seconds and +-- vnStat only writes to its database every few minutes anyway. +local CACHE = "/tmp/luci-netstat-vnstat-month" + +function M.vnstat_month_total(dev) + if not M.safe_name(dev) then return nil end + + local now = os.time() + local mtime = fs.stat(CACHE, "mtime") + if mtime and now - mtime < 60 and now >= mtime then + local c = fs.readfile(CACHE) or "" + local cdev, rx, tx = c:match("^(%S+) (%d+) (%d+)") + if cdev == dev then + return { rx = tonumber(rx), tx = tonumber(tx) } + end + end + + local raw = M.vnstat_json_raw("m", 1, dev) + local data = raw and jsonc.parse(raw) + if type(data) ~= "table" or type(data.interfaces) ~= "table" then return nil end + local ifc = data.interfaces[1] + if type(ifc) ~= "table" or type(ifc.traffic) ~= "table" then return nil end + + -- jsonversion 1 (vnStat 1.x) used plural keys and KiB units. + local legacy = tostring(data.jsonversion) == "1" + local months = ifc.traffic[legacy and "months" or "month"] + local scale = legacy and 1024 or 1 + + local rx, tx = 0, 0 + local last = type(months) == "table" and months[#months] + if type(last) == "table" and type(last.date) == "table" then + local t = os.date("*t", now) + if tonumber(last.date.year) == t.year and tonumber(last.date.month) == t.month then + rx = (tonumber(last.rx) or 0) * scale + tx = (tonumber(last.tx) or 0) * scale + end + end + + fs.writefile(CACHE, string.format("%s %.0f %.0f\n", dev, rx, tx)) + return { rx = rx, tx = tx } +end + +-- DatabaseDir from /etc/vnstat.conf, or vnStat's built-in default. +function M.vnstat_dbdir() + local conf = fs.readfile("/etc/vnstat.conf") or "" + for line in conf:gmatch("[^\n]+") do + local dir = line:match("^%s*DatabaseDir%s+\"([^\"]+)\"") + if dir then return dir end + end + return "/var/lib/vnstat" +end + +return M