🍓 Sync 2026-09-15 02:13:06

This commit is contained in:
github-actions[bot] committed 2026-09-15 02:13:06 +08:00
1 parent 0e5393594c
commit 1dd96ac2bb
146 files changed
+26541 -508

No files matched your search

+2 -2
View File
@@ -7,9 +7,9 @@
include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI Status Pages
LUCI_DEPENDS:=+luci-base +libiwinfo +rpcd-mod-iwinfo +ucode +ucode-mod-fs +ucode-mod-ubus +luci-app-oui
LUCI_DEPENDS:=+luci-base +luci-app-oui +libiwinfo +rpcd-mod-iwinfo +rpcd-mod-rrdns +ucode +ucode-mod-fs +ucode-mod-ubus +ucode-mod-socket
PKG_RELEASE:=8
PKG_RELEASE:=9
PKG_BUILD_DEPENDS:=iwinfo
PKG_LICENSE:=Apache-2.0
@@ -165,8 +165,8 @@ return baseclass.extend({
const fields = [
{ label: _('Active Connections'), value: ct_max ? ct_count : null },
{ label: _('Online Users'), value: onlineusers ? onlineusers.onlineusers : null },
{ label: _('Total download bandwidth'), value: this.bandwidthBar(download, capacity) },
{ label: _('Total upload bandwidth'), value: this.bandwidthBar(upload, capacity) }
{ label: _('Total upload bandwidth'), value: this.bandwidthBar(upload, capacity) },
{ label: _('Total download bandwidth'), value: this.bandwidthBar(download, capacity) }
];
const ctstatus = E('table', { 'class': 'table' });
@@ -44,7 +44,7 @@ return baseclass.extend({
if (!this.ouiLoader) {
this.ouiLoader = Promise.all([L.resolveDefault(uci.load('oui')), new Promise(function(resolve) {
const script = document.createElement('script');
script.src = L.resource('oui/oui.js') + '?v=7';
script.src = L.resource('oui/oui.js') + '?v=8';
script.onload = function() { resolve(window.luciOUI); };
script.onerror = function() { resolve(null); };
document.head.appendChild(script);
@@ -88,7 +88,13 @@ return baseclass.extend({
return this.rateValue(this.clientAddresses(lease, hints), data, direction);
},
rateValue(addresses, data, direction, mac) {
rateValue(addresses, data, direction, mac, online) {
if (direction == 'connections') {
if (online === false)
return [0, '0'];
const value = data?.connections?.[mac?.toUpperCase()];
return value != null ? [Number(value), String(value)] : [-1, '-'];
}
if (direction == 'total') {
const value = data?.totals?.[mac?.toUpperCase()];
return value != null ? [Number(value), '%1024.2mB'.format(value)] : [-1, '-'];
@@ -103,13 +109,14 @@ return baseclass.extend({
return addresses.length ? [ total, '%1024.1mB/s'.format(total) ] : [ -1, '-' ];
},
rateCell(lease, hints, direction) {
rateCell(lease, hints, direction, online) {
const addresses = this.clientAddresses(lease, hints);
const value = this.rateValue(addresses, this.rateData, direction, lease.macaddr);
const value = this.rateValue(addresses, this.rateData, direction, lease.macaddr, online);
return [ value[0], E('span', {
'class': 'luci-client-rate',
'data-addresses': JSON.stringify(addresses),
'data-direction': direction,
'data-online': online === false ? '0' : '1',
'data-mac': lease.macaddr || ''
}, value[1]) ];
},
@@ -197,7 +204,7 @@ return baseclass.extend({
this.rateData = data;
// Query again: the normal overview refresh may have replaced the rows.
document.querySelectorAll('.luci-client-rate').forEach(cell => {
const value = this.rateValue(JSON.parse(cell.dataset.addresses), data, cell.dataset.direction, cell.dataset.mac);
const value = this.rateValue(JSON.parse(cell.dataset.addresses), data, cell.dataset.direction, cell.dataset.mac, cell.dataset.online !== '0');
cell.textContent = value[1];
cell.closest('td')?.setAttribute('data-value', value[0]);
});
@@ -374,8 +381,9 @@ return baseclass.extend({
},
renderLeases(dhcp_leases, host_hints, macaddr, web, arp, history) {
const arpClients = this.arpLeases(arp);
const leases = [...(Array.isArray(dhcp_leases.dhcp_leases) ? dhcp_leases.dhcp_leases : []),
...this.arpLeases(arp),
...arpClients,
...(Array.isArray(history?.dhcp_leases) ? history.dhcp_leases : []).map(lease => ({ ...lease, _historical: true }))];
const leases6 = [...(Array.isArray(dhcp_leases.dhcp6_leases) ? dhcp_leases.dhcp6_leases : []),
...(Array.isArray(history?.dhcp6_leases) ? history.dhcp6_leases : []).map(lease => ({ ...lease, _historical: true }))];
@@ -403,9 +411,8 @@ return baseclass.extend({
};
const clients = this.mergeLeases(leases, leases6, host_hints);
const onlineMACs = new Set(String(arp || '').trim().split(/\n/).map(line => line.trim().split(/\s+/))
.filter(fields => fields.length == 6 && fields[5] == 'br-lan')
.map(fields => fields[3].toUpperCase()));
// Failed neighbours can retain their MAC in /proc/net/arp with flags 0x0.
const onlineMACs = new Set(arpClients.map(client => client.macaddr));
const table = E('table', { 'id': 'status_leases', 'class': 'table leases' }, [
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th' }, _('Online')),
@@ -416,6 +423,7 @@ return baseclass.extend({
E('th', { 'class': 'th' }, _('Upload')),
E('th', { 'class': 'th' }, _('Download')),
E('th', { 'class': 'th', 'data-total-traffic': '1' }, _('Total traffic')),
E('th', { 'class': 'th' }, _('Connection count')),
isReadonlyView ? E([]) : E('th', { 'class': 'th cbi-section-actions center' }, _('Static Lease'))
])
]);
@@ -423,9 +431,7 @@ return baseclass.extend({
this.initLeaseTable(table);
cbi_update_table(table, clients.map(client => {
const hint = machints.find(h => h[0].toUpperCase() == client.macaddr);
let host = client.hostname || hint?.[1];
if (hint && client.hostname && client.hostname != hint[1])
host = '%s (%s)'.format(client.hostname, hint[1]);
const host = client.hostname || hint?.[1];
const vendor = macaddr?.[client.macaddr?.toLowerCase()]?.vendor;
const online = onlineMACs.has(client.macaddr);
const status = online ? _('Online') : _('Offline');
@@ -444,7 +450,8 @@ return baseclass.extend({
vendor ? `${client.macaddr} (${vendor})` : client.macaddr || '-',
this.rateCell(client, host_hints, 'upload'),
this.rateCell(client, host_hints, 'download'),
this.rateCell(client, host_hints, 'total')
this.rateCell(client, host_hints, 'total'),
this.rateCell(client, host_hints, 'connections', online)
];
if (!isReadonlyView)
columns.push(this.leaseActions(client));
@@ -3,7 +3,8 @@
'use strict';
import { readfile, writefile, rename, chmod } from 'fs';
import { connect } from 'ubus';
import { arp_leases, update_history } from 'luci.client_history';
import { netbios_names } from 'luci.client_names';
import { arp_leases, update_history, reverse_names } from 'luci.client_history';
const path = '/tmp/luci-client-history.json';
const boot_id = trim(readfile('/proc/sys/kernel/random/boot_id') || '');
@@ -13,11 +14,24 @@ let previous = {};
try { previous = json(readfile(path) || '{}'); } catch (e) {}
const bus = connect();
let current = bus ? bus.call('luci-rpc', 'getDHCPLeases', {}) : null;
current ||= {};
const active = arp_leases(readfile('/proc/net/arp'));
const reverse_dns = reverse_names(previous, current, active, boot_id,
int(split(readfile('/proc/uptime'), ' ')[0]), function(addrs) {
let names = bus ? (bus.call('network.rrdns', 'lookup', { addrs, timeout: 1000, limit: 8 }) || {}) : {};
return { ...names, ...netbios_names(filter(addrs, ip => !names[ip])) };
});
if (bus)
bus.disconnect();
current ||= {};
current.dhcp_leases = [ ...(current.dhcp_leases || []), ...arp_leases(readfile('/proc/net/arp')) ];
const next = sprintf('%J', update_history(previous, current, boot_id));
// Recheck ownership after the asynchronous DNS query.
const owners = {};
for (let lease in arp_leases(readfile('/proc/net/arp')))
owners[lease.ipaddr] = lease.macaddr;
current.dhcp_leases = [ ...(current.dhcp_leases || []),
...filter(active, lease => owners[lease.ipaddr] == lease.macaddr) ];
const result = update_history(previous, current, boot_id);
result.reverse_dns = reverse_dns;
const next = sprintf('%J', result);
if (next != readfile(path)) {
if (writefile(path + '.new', next) != length(next))
exit(1);
@@ -47,3 +47,43 @@ export function update_history(previous, current, boot_id) {
}
return result;
};
// Cache both successful and unsuccessful PTR lookups. Bind results to MAC + IP
// so an address handed to another client cannot inherit the former name.
export function reverse_names(previous, current, active, boot_id, now, lookup) {
let old = type(previous) == 'object' && previous.boot_id == boot_id &&
type(previous.reverse_dns) == 'object' ? previous.reverse_dns : {};
let cache = {}, named = {}, pending = [];
for (let family in ['dhcp_leases', 'dhcp6_leases'])
for (let lease in current[family] || [])
if (type(lease.hostname) == 'string' && length(lease.hostname) &&
lease.hostname != '*' && lease.hostname != lease.ipaddr && lease.macaddr)
named[uc(lease.macaddr)] = true;
for (let lease in active) {
if (named[lease.macaddr])
continue;
let key = lease.macaddr + '@' + lease.ipaddr;
let entry = type(old[key]) == 'object' ? old[key] : {};
cache[key] = entry;
if ((!entry.next || entry.next <= now) && length(pending) < 32) {
push(pending, lease.ipaddr);
entry.next = now + 300;
}
}
let answers = length(pending) ? lookup(pending) : {};
for (let lease in active) {
let entry = cache[lease.macaddr + '@' + lease.ipaddr];
if (!entry)
continue;
let name = answers?.[lease.ipaddr];
if (type(name) == 'string') {
name = replace(name, /\.$/, '');
if (length(name) <= 253 && match(name, /^[a-z0-9_][a-z0-9_.-]*$/i) &&
!match(name, /^[0-9.]+$/))
entry.hostname = name;
}
if (entry.hostname)
lease.hostname = entry.hostname;
}
return cache;
};
@@ -0,0 +1,75 @@
// SPDX-License-Identifier: Apache-2.0
// RFC 1002 NBSTAT: unicast queries only, to known LAN clients.
import { create, poll, AF_INET, SOCK_DGRAM, POLLIN, MSG_DONTWAIT } from 'socket';
function byte(data, offset) { return ord(substr(data, offset, 1)); };
function word(data, offset) { return byte(data, offset) * 256 + byte(data, offset + 1); };
function skip_name(data, offset) {
while (offset < length(data)) {
let size = byte(data, offset++);
if (!size) return offset;
if ((size & 0xc0) == 0xc0)
return offset < length(data) ? offset + 1 : -1;
if (size > 63 || offset + size > length(data)) return -1;
offset += size;
}
return -1;
};
export function nbstat_name(data, id) {
if (type(data) != 'string' || length(data) < 12 || word(data, 0) != id ||
(word(data, 2) & 0xfa0f) != 0x8000 || word(data, 6) != 1)
return null;
let offset = 12;
for (let i = 0; i < word(data, 4); i++) {
offset = skip_name(data, offset);
if (offset < 0 || offset + 4 > length(data)) return null;
offset += 4;
}
offset = skip_name(data, offset);
if (offset < 0 || offset + 11 > length(data) || word(data, offset) != 0x21 ||
word(data, offset + 2) != 1) return null;
let size = word(data, offset + 8);
offset += 10;
if (size < 1 || offset + size > length(data)) return null;
let count = byte(data, offset++), server = null;
if (1 + count * 18 > size) return null;
for (let i = 0; i < count; i++, offset += 18) {
let suffix = byte(data, offset + 15), flags = word(data, offset + 16);
let name = trim(substr(data, offset, 15));
if ((flags & 0x8000) || !match(name, /^[a-z0-9_][a-z0-9_.-]*$/i)) continue;
if (suffix == 0) return name;
if (suffix == 0x20) server = name;
}
return server;
};
export function netbios_names(addresses) {
let jobs = [], result = {};
for (let ip in addresses) {
if (length(jobs) >= 32) break;
let sock = create(AF_INET, SOCK_DGRAM);
if (!sock) continue;
let id = (int(time()) + length(jobs)) & 65535;
let query = chr(id >> 8) + chr(id & 255) + '\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00' +
'\x20CKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\x00\x00\x21\x00\x01';
if (!sock.connect({ family: AF_INET, address: ip, port: 137 }) || sock.send(query) != length(query)) {
sock.close();
continue;
}
push(jobs, [sock, POLLIN, ip, id]);
}
// Batch all clients; never wait one second per address.
for (let round = 0; round < 4 && length(jobs); round++) {
let events = poll(250, ...jobs) || [];
for (let event in events) {
if (!event[1]) continue;
let name = nbstat_name(event[0].recv(4096, MSG_DONTWAIT), event[3]);
if (name) result[event[2]] = name;
event[0].close();
jobs = filter(jobs, job => job[2] != event[2]);
}
}
for (let job in jobs) job[0].close();
return result;
};
+14
View File
@@ -17,3 +17,17 @@ Run `ucode client-history.uc` on a target with the installed
restart, IP reassignment, changing IPv6 addresses and boot-ID reset without
rebooting the device. Run the existing frontend tests with
`node libs/rpcd-mod-luci/tests/traffic-ui.js` from the LuCI repository.
For unnamed clients in the resolved LAN ARP table, the collector attempts DNS
PTR lookup, then unicast NetBIOS NBSTAT (UDP 137) when DNS has no answer.
Positive and negative results are cached for five minutes, keyed by MAC and IP.
Each pass considers at most 32 due addresses; DNS and NetBIOS each have a
one-second batch wait budget. Names are retained with the boot-local history;
DHCP names take precedence. Devices without PTR or NetBIOS service keep their
existing label. No subnet-wide or broadcast scan is performed.
Run `ucode client-names.uc` with the installed `luci.client_history`,
`luci.client_names` and `ucode-mod-socket` modules. It checks positive/negative
caching, retry timing, address reuse, DHCP priority, boot reset and malformed
NBSTAT responses. For a live check, compare `netbios_names([ip])` with a known
NetBIOS responder; an unanswered query is not evidence of an offline client.
+35
View File
@@ -0,0 +1,35 @@
import { reverse_names } from 'luci.client_history';
import { nbstat_name, netbios_names } from 'luci.client_names';
function check(v, message) { if (!v) die(message + '\n'); };
let calls = 0;
const mac = '00:0C:29:D2:60:68', ip = '192.168.0.104';
function active() { return [{macaddr:mac,ipaddr:ip}]; };
let leases = active();
let cache = reverse_names({}, {}, leases, 'boot', 10, function(ips) {
calls++; return {[ip]:'lean-esx.lan.'};
});
check(leases[0].hostname == 'lean-esx.lan', 'PTR name normalized');
let previous = {boot_id:'boot',reverse_dns:cache};
leases = active();
reverse_names(previous, {}, leases, 'boot', 20, function() { die('cache missed'); });
check(leases[0].hostname == 'lean-esx.lan', 'cached hostname restored');
reverse_names(previous, {}, active(), 'boot', 310, function() { calls++; return {}; });
check(calls == 2, 'refresh after five minutes');
leases = [{macaddr:'00:11:22:33:44:55',ipaddr:ip}];
let miss = reverse_names(previous, {}, leases, 'boot', 20, function() {calls++; return {};});
check(!leases[0].hostname && calls == 3, 'address reuse does not inherit name');
reverse_names({boot_id:'boot',reverse_dns:miss}, {}, leases, 'boot', 21, function() {die('negative cache missed');});
reverse_names(previous, {dhcp_leases:[{macaddr:mac,hostname:'dhcp-name'}]}, active(), 'boot', 999, function() {die('queried named DHCP client');});
reverse_names(previous, {}, active(), 'reboot', 1, function() {calls++;return {};});
check(calls == 4, 'boot resets cache');
// RFC 1002 response: skip group name and prefer the workstation name.
const records = 'WORKGROUP \x00' + chr(0x80) + '\x00' + 'LEAN-ESX \x20\x00\x00' + 'LEAN-ESX \x00\x00\x00';
const packet = '\x12\x34' + chr(0x84) + '\x00\x00\x00\x00\x01\x00\x00\x00\x00' +
'\x20CKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\x00' +
'\x00\x21\x00\x01\x00\x00\x00\x00\x00\x37\x03' + records;
check(nbstat_name(packet,0x1234) == 'LEAN-ESX', 'NBSTAT hostname');
check(!nbstat_name(packet,0x5678), 'transaction mismatch');
for (let i=0;i<length(packet);i++)
check(!nbstat_name(substr(packet,0,i),0x1234), 'truncated response accepted');
check(!nbstat_name('\x12\x34' + chr(0x84) + '\x00\x00\x00\x00\x01\x00\x00\x00\x00' + chr(0xc0) + '',0x1234), 'truncated pointer');
print('client names: cache, retry, ownership, DHCP priority, reboot and NBSTAT validation passed\n');