mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-01 16:44:23 +08:00
🍓 Sync 2026-09-15 02:13:06
This commit is contained in:
1 parent
0e5393594c
commit
1dd96ac2bb
146 files changed
+26541
-508
No files matched your search
@@ -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;
|
||||
};
|
||||
@@ -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.
|
||||
@@ -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');
|
||||
Reference in new issue
Block a user