🎈 Sync 2026-09-16 00:45:17

This commit is contained in:
github-actions[bot] committed 2026-09-16 00:45:17 +08:00
1 parent d5fe29717e
commit afb665d9c0
10 files changed
+3785

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
# Client connection rates and boot-session totals
`luci.client-rates get` accepts up to 1024 IP addresses and returns cached upload
and download bytes/second for each address, plus cumulative upload + download
bytes keyed by MAC in `totals`. It reads `NETLINK_NETFILTER` connection
counters; it never installs packet-filter rules or resets kernel counters.
On QSDK, ECM's NSS/SFE/PPE statistics synchronization updates these conntrack
counters. NSS IPv4/IPv6 synchronization is normally once per second. This code
samples at two-second intervals and computes a rolling average over the last
three valid samples (about six seconds), weighted by actual elapsed time. The
window grows gradually after the initial baseline; invalid samples clear it.
The homepage refreshes only the rate cells every two seconds, independently of
the slower lease/status polling. Sampling starts when rpcd loads the plugin and
continues without any browser requests. Every sample discovers IPv4/IPv6
neighbours and current DHCP leases without reverse-DNS queries. Current leases
win over stale neighbour records. Totals are keyed by MAC and each connection
retains its original owners, so address reuse does not transfer old traffic.
An atomic checkpoint of totals, address bindings and connection baselines is
written to `/tmp/luci-client-traffic.state` after each completed sample. This is
RAM-backed on OpenWrt, not flash. The boot ID and format are checked on restore;
a router reboot starts a new accounting session. rpcd reloads retain totals and
only add subsequent deltas for surviving connections. Short connections that
end during a collector outage cannot be recovered; `incomplete` reports such
interruptions or accounting/receive limits. This is best-effort monitoring, not
billing accounting. Installing on a running router establishes a fresh baseline;
it cannot reconstruct connections that ended before installation.
The cache is bounded to 4096 MACs, 8192 addresses and 32768 connection records.
Unused addresses expire after a day, but MAC totals remain for this boot.
Disconnected/unsupported platforms retry accounting availability every ten
seconds. They do not enable sysctls or install kernel modules automatically.
The first complete dump establishes a baseline. A second sample produces rates.
Original-source bytes are upload; reply-source bytes are download, including
DNAT. The reverse direction is assigned accordingly. Destroy notifications
capture final counters for short flows. A bounded per-flow cache prevents whole
connection lifetimes being counted again at every poll. Totals accumulate raw
byte deltas, never the displayed or averaged rates. Interrupted dumps,
receive overruns and limits invalidate the sample instead of fabricating a rate.
The accounting sysctl must already be enabled and conntrack netlink available.
The reader does not change these settings. Unsupported or warming-up values are
shown as `-`. Inactive supported clients show zero. This is routed/conntracked
traffic, not a physical port meter: pure layer-2 switched traffic and traffic
which never appears in conntrack are outside its scope. Proxies terminating on
the router are measured on their client-side connections. Bytes reflect IP
accounting, not Ethernet wire overhead. The DHCP table combines known addresses
for the same MAC, including IPv6 addresses from host hints, and normalizes them
to avoid duplicates. DUID/IAID remain in the lease objects for static reservations.
## Regression checks
Run `node tests/traffic-ui.js` from this package directory. It exercises address
normalization, IPv4/IPv6 aggregation, direction, sorting keys and warming-up UI.
Build `tests/traffic.c` with the OpenWrt target compiler, the staging include/lib
paths, and `-lubus -lubox`, then execute the binary on a compatible target. It
constructs real netlink attribute layouts for IPv4, IPv6, DNAT, counter resets,
short-lived flows, a delayed dump record after destroy, baseline/rate calculation
rolling-window expiry, unequal sampling intervals, invalid-window reset, exact
byte totals, checkpoint restore, IP reassignment and unattended sampling. Test programs are not included in the installed package.
The integration check should compare conntrack byte deltas with ECM connection
statistics while the matching connection is accelerated, then check the real
homepage with traffic. Do not infer accelerated accounting solely from a package
build or a nonzero interface counter.
`connections` contains the latest completed conntrack dump count per MAC,
combining IPv4/IPv6 and counting a flow only once when both ends have the same
MAC. It includes idle tracked entries and counts records even without byte
counters. It is a two-second snapshot, not a TCP ESTABLISHED-only count.
Failed or stale snapshots return no counts, rendered as `-`; a valid empty
snapshot returns zero. Counts are not restored from historical traffic state.
+173
View File
@@ -0,0 +1,173 @@
/* SPDX-License-Identifier: Apache-2.0 */
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const luci = path.resolve(__dirname, '../../..');
const baseclass = { extend: o => o };
const validation = new Function('baseclass', fs.readFileSync(path.join(luci, 'modules/luci-base/htdocs/luci-static/resources/validation.js'), 'utf8'))(baseclass);
const source = fs.readFileSync(path.join(luci, 'modules/luci-mod-status/htdocs/luci-static/resources/view/status/include/40_dhcp.js'), 'utf8');
const L = { bind: (fn, ctx, ...args) => fn.bind(ctx, ...args), hasSystemFeature: () => false, resolveDefault: (p, fallback) => Promise.resolve(p).catch(() => fallback), toArray: x => x == null ? [] : Array.isArray(x) ? x : [x] };
let cells = [], calls = 0, polls = [], reply;
const document = {querySelectorAll: selector => selector.startsWith('#status_leases') ? [] : cells};
const poll = {add: (fn, interval) => polls.push({fn, interval})};
const rpc = {declare: spec => addresses => {
assert.equal(spec.object, 'luci.client-rates');
assert.deepEqual(addresses, ['192.168.0.2']);
calls++;
return reply;
}};
const view = new Function('baseclass','rpc','L','_','validation','poll','document','E', source)(baseclass, rpc, L, s=>s, validation, poll, document, (tag, attrs, text) => ({tag,attrs,text}));
// Formatting is supplied by LuCI; check its numeric sorting key separately.
String.prototype.format = function(value) { return String(value) + ' B/s'; };
const lease = {ipaddr:'192.168.0.2', macaddr:'aa:bb:cc:dd:ee:ff',ip6addrs:['2001:db8::2/128']};
const hints = {hosts: {'AA:BB:CC:DD:EE:FF':{ipaddrs:['192.168.0.2'],ip6addrs:['2001:0db8:0:0:0:0:0:2']}}};
assert.deepEqual(view.clientAddresses(lease,hints), ['192.168.0.2','2001:db8:0:0:0:0:0:2']);
const rates={rates:{'192.168.0.2':{ready:true,upload:100,download:200},'2001:db8:0:0:0:0:0:2':{ready:true,upload:300,download:400}}};
assert.equal(view.renderRate(lease,hints,rates,'upload')[0],400);
assert.equal(view.renderRate(lease,hints,rates,'download')[0],600);
assert.equal(view.renderRate(lease,hints,{},'upload')[1],'-');
assert.deepEqual(view.clientAddresses({ipaddr:'invalid'}, {hosts:{}}),[]);
const mac = 'AA:BB:CC:DD:EE:FF';
const totals = {...rates, totals:{[mac]:123456}};
assert.equal(view.rateValue(view.clientAddresses(lease,hints),totals,'total',mac)[0],123456);
assert.equal(view.rateValue(['192.168.0.2'],totals,'total',mac)[0],123456);
assert.equal(view.rateValue([],totals,'total',mac.toLowerCase())[0],123456);
assert.equal(view.rateValue([],{},'total',mac)[1],'-');
assert.equal(view.rateValue([], {totals:{[mac]:0}}, 'total',mac)[0],0);
assert.deepEqual(view.rateValue([], {connections:{[mac]:12}}, 'connections', mac), [12,'12']);
assert.deepEqual(view.rateValue([], {connections:{[mac]:0}}, 'connections', mac), [0,'0']);
assert.deepEqual(view.rateValue([], {}, 'connections', mac), [-1,'-']);
assert.deepEqual(view.rateValue([], {connections:{[mac]:6}}, 'connections', mac, false), [0,'0']);
assert.deepEqual(view.rateValue([], {}, 'connections', mac, false), [0,'0']);
assert.deepEqual(view.rateValue([], {connections:{[mac]:6}}, 'connections', mac, true), [6,'6']);
const web = ports => ({clients:{'192.168.0.2':{mac, ready:true, ports}}});
assert.equal(view.clientURL(lease, web([4430,443,8080,80])), 'http://192.168.0.2/');
assert.equal(view.clientURL(lease, web([443,8080])), 'http://192.168.0.2:8080/');
assert.equal(view.clientURL(lease, web([4430,443])), 'https://192.168.0.2/');
assert.equal(view.clientURL(lease, web([4430])), 'https://192.168.0.2:4430/');
assert.equal(view.clientURL(lease, web([5666,443,5667])), 'http://192.168.0.2:5666/');
assert.equal(view.clientURL(lease, web([5667])), 'https://192.168.0.2:5667/');
assert.equal(view.clientURL(lease, web([])), null);
assert.equal(view.clientURL(lease, web([22])), null);
assert.equal(view.clientURL({...lease,macaddr:'00:11:22:33:44:55'}, web([80])), null);
assert.equal(view.clientURL({...lease,ipaddr:'javascript:alert(1)'}, web([80])), null);
assert.equal(view.clientURL(lease, {}), null);
const fnosClient = {macaddr: lease.macaddr, activeAddresses:['192.168.0.3', lease.ipaddr, '2001:db8::2']};
assert.equal(view.isFnosClient(fnosClient, web([5666])), true);
assert.equal(view.isFnosClient(fnosClient, web([5667])), true);
assert.equal(view.isFnosClient(fnosClient, web([80,443])), false);
assert.equal(view.isFnosClient(fnosClient, {}), false);
assert.equal(view.isFnosClient({...fnosClient, activeAddresses:[]}, web([5666])), false);
assert.equal(view.isFnosClient({...fnosClient, macaddr:'00:11:22:33:44:55'}, web([5666])), false);
assert.equal(view.isFnosClient(fnosClient, {clients:{[lease.ipaddr]:{mac,ready:false,ports:[5666]}}}), false);
const link = view.renderClientIP(lease, web([5666,443]));
assert.equal(link.tag, 'a');
assert.equal(link.text, lease.ipaddr);
assert.equal(link.attrs.href, 'http://192.168.0.2:5666/');
assert.equal(link.attrs.target, '_blank');
assert.equal(link.attrs.style, 'text-decoration:underline');
assert.equal(view.renderClientIP(lease, web([])), lease.ipaddr);
const arp = `IP address HW type Flags HW address Mask Device
192.168.0.2 0x1 0x2 aa:bb:cc:dd:ee:ff * br-lan
192.168.0.8 0x1 0x6 00:11:22:33:44:66 * br-lan
192.168.0.9 0x1 0x0 00:11:22:33:44:77 * br-lan
192.168.9.1 0x1 0x2 00:11:22:33:44:88 * eth1
192.168.0.10 0x1 0x2 00:00:00:00:00:00 * br-lan
192.168.0.11 0x1 0x2 01:00:5e:00:00:01 * br-lan
invalid 0x1 0x2 00:11:22:33:44:99 * br-lan`;
const arpClients=view.arpLeases(arp);
assert.equal(arpClients.length,2);
assert.equal(arpClients[0].macaddr,mac);
assert.equal(view.arpLeases('').length,0);
assert.equal(view.mergeLeases(arpClients,[],{hosts:{}}).length,2);
assert.equal(view.mergeLeases([{macaddr:mac,ipaddr:'192.168.0.2'},...arpClients],[],{hosts:{}}).length,2);
// One row per known MAC, with all family addresses and original reservation IDs.
const v4 = [
{macaddr:mac.toLowerCase(), hostname:'desktop', ipaddr:'192.168.0.2'},
{macaddr:mac, hostname:'desktop', ipaddr:'192.168.0.3'},
{macaddr:'00:11:22:33:44:55', hostname:'desktop', ipaddr:'192.168.0.4'}
];
const v6 = [
{macaddr:mac, hostname:'desktop', ip6addr:'2001:db8::2', ip6addrs:['2001:db8::2/128'], duid:'DUID1', iaid:'01', interface:'br-lan'},
{macaddr:mac.toLowerCase(), ip6addrs:['2001:0db8:0:0:0:0:0:2/128','2001:db8::3/128'], duid:'DUID1', iaid:'01'},
{hostname:'desktop', ip6addrs:['2001:db8::4/128'], duid:'opaque'},
{hostname:'desktop', ip6addrs:['2001:db8::5/128'], duid:'another'}
];
const input = JSON.stringify([v4,v6]);
const merged = view.mergeLeases(v4,v6,{hosts:{}});
assert.equal(merged.length,4); // Equal names with unknown or different MACs stay separate.
assert.equal(merged[0].macaddr,mac);
assert.deepEqual(merged[0].ipaddrs,['192.168.0.2','192.168.0.3']);
assert.deepEqual(merged[0].ip6addrs,['2001:db8::2','2001:db8::3/128']);
assert.equal(merged[0].leases6.length,1);
assert.equal(merged[0].leases6[0].duid,'DUID1');
assert.equal(merged[0].leases6[0].iaid,'01');
assert.deepEqual(merged[0].interfaces,['br-lan']);
assert.equal(JSON.stringify([v4,v6]),input);
const combined = view.mergeLeases([v4[0]],[v6[0]],hints)[0];
assert.deepEqual(view.clientAddresses(combined,hints),view.clientAddresses(lease,hints));
assert.equal(view.rateValue(view.clientAddresses(combined,hints),totals,'download',mac)[0],600);
assert.equal(view.rateValue(view.clientAddresses(combined,hints),totals,'total',mac)[0],123456);
const inferred = view.mergeLeases([v4[0]],[{...v6[0],macaddr:null}],hints);
assert.equal(inferred.length,1);
assert.equal(inferred[0].leases6[0].macaddr,mac);
const conflicting = {hosts:{...hints.hosts,'00:11:22:33:44:55':hints.hosts[mac]}};
assert.equal(view.mergeLeases([v4[0]],[{...v6[0],macaddr:null}],conflicting).length,2);
assert.equal(view.mergeLeases([],[{macaddr:'00:00:00:00:00:00'},{}],{hosts:{}}).length,2);
assert.deepEqual(view.mergeLeases([],[],{hosts:{}}),[]);
const archived = view.mergeLeases([{...v4[0],_historical:true}], [{...v6[0],_historical:true}], hints)[0];
assert.equal(archived.ipaddrs[0],'192.168.0.2');
assert.equal(archived.ip6addrs[0],'2001:db8::2');
assert.equal(archived.activeAddresses.length,0);
assert.equal(archived.leases.length,0);
assert.equal(view.clientAddresses(archived,hints).length,0);
assert.equal(view.rateValue([],totals,'total',mac)[0],123456);
const returned = view.mergeLeases([v4[0],{...v4[0],_historical:true}],[],hints);
assert.equal(returned.length,1);
assert.equal(returned[0].leases.length,1);
assert.equal(returned[0].ipaddrs.length,1);
const unknownHistory = view.mergeLeases([], [v6[2], {...v6[2],_historical:true}], {hosts:{}});
assert.equal(unknownHistory.length,1);
assert.equal(unknownHistory[0].leases6.length,1);
const old4=view.handleCreateStaticLease, old6=view.handleCreateStaticLease6;
view.handleCreateStaticLease=record=>record;
view.handleCreateStaticLease6=record=>record;
view.isDUIDIAIDStatic={'duid1%01':true};
const actions=view.leaseActions(combined).text;
assert.equal(actions.length,2);
assert.equal(actions[0].attrs.click().ipaddr,'192.168.0.2');
assert.equal(actions[1].attrs.click().duid,'DUID1');
assert.equal(actions[1].attrs.disabled,true);
view.handleCreateStaticLease=old4; view.handleCreateStaticLease6=old6;
console.log('traffic UI: MAC lease merging, unknown/conflicting identities and reservation actions passed');
console.log('traffic UI: address normalization, no double-counting, directions and warm-up passed');
(async () => {
view.render([{}, {}]);
view.render([{}, {}]);
assert.equal(polls.length, 1);
assert.equal(polls[0].interval, 2);
await view.refreshRates();
assert.equal(calls, 0); // No mounted table means no background requests.
function cell(direction) {
return {dataset:{addresses:'["192.168.0.2"]', direction},
closest() {return {setAttribute: (name, value) => {this.sort = value;}};}};
}
cells = [cell('upload'), cell('download')];
reply = new Promise(resolve => {global.resolveRates = resolve;});
const pending = view.refreshRates();
// Simulate the normal status poll replacing the table during the RPC.
const oldCells = cells;
cells = [cell('upload'), cell('download')];
global.resolveRates({rates:{'192.168.0.2':{ready:true,upload:100,download:200}}});
await pending;
assert.equal(calls, 1);
assert.equal(cells[0].textContent, '100 B/s');
assert.equal(cells[1].sort, 200);
assert.equal(oldCells[0].textContent, undefined);
reply = Promise.reject(new Error('unavailable'));
await view.refreshRates();
assert.equal(cells[0].textContent, '-');
assert.equal(cells[0].sort, -1);
console.log('traffic UI: independent polling, deduplication, row replacement and error fallback passed');
})().catch(e => {console.error(e); process.exitCode = 1;});
+207
View File
@@ -0,0 +1,207 @@
/* SPDX-License-Identifier: Apache-2.0 */
#include <assert.h>
#define STATE_FILE "/tmp/luci-client-traffic.test.state"
#include "../src/traffic.c"
void rpc_luci_traffic_discover(void (*add)(int, const void *, const unsigned char *)) {}
static void put(struct nlmsghdr *msg, int type, const void *data, size_t len)
{
struct nlattr *a = (void *)((char *)msg + NLMSG_ALIGN(msg->nlmsg_len));
a->nla_type = type;
a->nla_len = NLA_HDRLEN + len;
memcpy((char *)a + NLA_HDRLEN, data, len);
msg->nlmsg_len = NLMSG_ALIGN(msg->nlmsg_len) + NLA_ALIGN(a->nla_len);
}
static struct nlattr *begin(struct nlmsghdr *msg, int type)
{
struct nlattr *a = (void *)((char *)msg + NLMSG_ALIGN(msg->nlmsg_len));
a->nla_type = type | NLA_F_NESTED;
a->nla_len = NLA_HDRLEN;
msg->nlmsg_len += NLA_HDRLEN;
return a;
}
static void end(struct nlmsghdr *msg, struct nlattr *a)
{
a->nla_len = (char *)msg + msg->nlmsg_len - (char *)a;
}
static void tuple(struct nlmsghdr *msg, int type, const char *src, const char *dst)
{
struct address a;
struct nlattr *t = begin(msg, type), *ip = begin(msg, CTA_TUPLE_IP);
parse_address(src, &a);
put(msg, a.family == AF_INET ? CTA_IP_V4_SRC : CTA_IP_V6_SRC, a.bytes, a.family == AF_INET ? 4 : 16);
parse_address(dst, &a);
put(msg, a.family == AF_INET ? CTA_IP_V4_DST : CTA_IP_V6_DST, a.bytes, a.family == AF_INET ? 4 : 16);
end(msg, ip);
struct nlattr *proto = begin(msg, CTA_TUPLE_PROTO);
uint8_t num = 6;
uint16_t port = htons(1234);
put(msg, CTA_PROTO_NUM, &num, 1);
put(msg, CTA_PROTO_SRC_PORT, &port, 2);
put(msg, CTA_PROTO_DST_PORT, &port, 2);
end(msg, proto);
end(msg, t);
}
static void counters(struct nlmsghdr *msg, int type, uint64_t bytes)
{
unsigned char b[8];
for (int i = 7; i >= 0; i--) { b[i] = bytes & 255; bytes >>= 8; }
struct nlattr *a = begin(msg, type);
put(msg, CTA_COUNTERS_BYTES, b, 8);
end(msg, a);
}
static void feed(int family, const char *src, const char *dst, const char *reply_src,
uint32_t id, uint64_t up, uint64_t down, bool dead)
{
char buffer[2048] = {0};
struct nlmsghdr *msg = (void *)buffer;
struct nfgenmsg *nf = NLMSG_DATA(msg);
msg->nlmsg_len = NLMSG_LENGTH(sizeof(*nf));
msg->nlmsg_type = (NFNL_SUBSYS_CTNETLINK << 8) | (dead ? IPCTNL_MSG_CT_DELETE : IPCTNL_MSG_CT_NEW);
nf->nfgen_family = family;
tuple(msg, CTA_TUPLE_ORIG, src, dst);
tuple(msg, CTA_TUPLE_REPLY, reply_src, src);
put(msg, CTA_ID, &id, 4);
counters(msg, CTA_COUNTERS_ORIG, up);
counters(msg, CTA_COUNTERS_REPLY, down);
assert(process_flow(msg));
}
static void count_record(int family, const char *src, const char *dst, const char *reply_src)
{
char buffer[2048] = {0};
struct nlmsghdr *msg = (void *)buffer;
struct nfgenmsg *nf = NLMSG_DATA(msg);
msg->nlmsg_len = NLMSG_LENGTH(sizeof(*nf));
nf->nfgen_family = family;
tuple(msg, CTA_TUPLE_ORIG, src, dst);
tuple(msg, CTA_TUPLE_REPLY, reply_src, src);
/* Missing byte counters must not prevent connection counting. */
count_connection(msg);
}
static struct client *client(const char *ip)
{
struct client *c = calloc(1, sizeof(*c));
assert(parse_address(ip, &c->address));
c->mac[0] = 2;
c->mac[5] = c->address.bytes[c->address.family == AF_INET ? 3 : 15];
get_host(c->mac, true);
c->avl.key = &c->address;
avl_insert(&clients, &c->avl);
return c;
}
int main(void)
{
avl_init(&clients, address_cmp, false, NULL);
avl_init(&flows, flow_cmp, false, NULL);
avl_init(&hosts, mac_cmp, false, NULL);
started = now_ms();
struct client *a = client("192.168.0.100"), *b = client("192.168.0.101"), *v6 = client("fd00::2");
unsigned char v6mac[6];
memcpy(v6mac, v6->mac, 6);
memcpy(v6->mac, a->mac, 6);
count_record(AF_INET, "192.168.0.100", "8.8.8.8", "8.8.8.8");
count_record(AF_INET6, "fd00::2", "2001:db8::1", "2001:db8::1");
count_record(AF_INET, "8.8.8.8", "203.0.113.1", "192.168.0.101");
count_record(AF_INET, "192.168.0.100", "203.0.113.1", "192.168.0.100");
assert(get_host(a->mac, false)->pending_connections == 3);
assert(get_host(b->mac, false)->pending_connections == 1);
assert(get_host(a->mac, false)->connections == 0);
get_host(a->mac, false)->pending_connections = 0;
get_host(b->mac, false)->pending_connections = 0;
memcpy(v6->mac, v6mac, 6);
feed(AF_INET, "192.168.0.100", "8.8.8.8", "8.8.8.8", 1, 1000, 2000, false);
assert(a->bytes[0] == 0 && a->bytes[1] == 0); /* First sample is a baseline. */
finish_dump();
assert(a->warm && !a->ready);
assert(get_host(a->mac, false)->total[0] == 0);
generation++;
feed(AF_INET, "192.168.0.100", "8.8.8.8", "8.8.8.8", 1, 3000, 7000, false);
assert(a->bytes[0] == 2000 && a->bytes[1] == 5000);
/* DNAT: original packets are downloads to reply.src, replies are uploads. */
feed(AF_INET, "8.8.4.4", "203.0.113.1", "192.168.0.101", 2, 10000, 100, false);
assert(b->bytes[0] == 100 && b->bytes[1] == 10000);
feed(AF_INET6, "fd00::2", "2001:db8::1", "2001:db8::1", 3, 500, 1000, false);
assert(v6->bytes[0] == 500 && v6->bytes[1] == 1000);
/* Destroy notifications include the final bytes of short-lived flows. */
feed(AF_INET, "192.168.0.100", "1.1.1.1", "1.1.1.1", 4, 80, 200, true);
assert(a->bytes[0] == 2080 && a->bytes[1] == 5200);
feed(AF_INET, "192.168.0.100", "1.1.1.1", "1.1.1.1", 4, 80, 200, false);
assert(a->bytes[0] == 2080 && a->bytes[1] == 5200); /* Late dump after destroy. */
feed(AF_INET, "192.168.0.100", "8.8.8.8", "8.8.8.8", 1, 20, 30, false);
assert(a->bytes[0] == 2080 && a->bytes[1] == 5200); /* Reset cannot underflow. */
sampled = now_ms() - 2000;
finish_dump();
assert(a->ready && a->rate[0] >= 1035 && a->rate[0] <= 1040);
assert(a->rate[1] >= 2590 && a->rate[1] <= 2600);
/* A burst must stay in the rolling window, even across two quiet samples. */
struct client rolling = { .ready = true, .bytes = {6000, 12000} };
update_rate(&rolling, 2000);
assert(rolling.rate[0] == 3000 && rolling.rate[1] == 6000);
update_rate(&rolling, 2000);
update_rate(&rolling, 2000);
assert(rolling.rate[0] == 1000 && rolling.rate[1] == 2000);
update_rate(&rolling, 2000);
assert(rolling.rate[0] == 0 && rolling.rate[1] == 0);
/* Unequal intervals use sum(bytes) / sum(time), not mean(rate). */
rolling.bytes[0] = 12000;
update_rate(&rolling, 4000);
assert(rolling.rate[0] == 1500);
rolling.ready = false;
update_rate(&rolling, 2000);
assert(rolling.rate[0] == 0 && rolling.duration[0] == 0);
rolling.ready = true;
rolling.bytes[0] = 2000;
update_rate(&rolling, 2000);
assert(rolling.rate[0] == 1000);
/* Total bytes use exact deltas, never the rolling average. */
struct host *ha = get_host(a->mac, false);
assert(ha->total[0] == 2080 && ha->total[1] == 5200);
uint64_t before = ha->total[0] + ha->total[1];
save_state();
struct client *c, *cn;
struct host *h, *hn;
clear_flows();
avl_for_each_element_safe(&clients, c, avl, cn) { avl_delete(&clients, &c->avl); free(c); }
avl_for_each_element_safe(&hosts, h, avl, hn) { avl_delete(&hosts, &h->avl); free(h); }
load_state();
struct address address;
parse_address("192.168.0.100", &address);
a = avl_find_element(&clients, &address, a, avl);
assert(a && !a->warm);
ha = get_host(a->mac, false);
assert(ha->total[0] + ha->total[1] == before);
/* Restored baselines only count the increment across an rpcd reload. */
feed(AF_INET, "192.168.0.100", "8.8.8.8", "8.8.8.8", 1, 120, 230, false);
assert(ha->total[0] + ha->total[1] == before + 300);
/* An IP reassignment must not transfer the old connection to a new MAC. */
unsigned char newmac[6] = {2, 1, 2, 3, 4, 5};
bind_client(AF_INET, address.bytes, newmac);
feed(AF_INET, "192.168.0.100", "8.8.8.8", "8.8.8.8", 1, 220, 330, false);
assert(ha->total[0] + ha->total[1] == before + 500);
assert(get_host(newmac, false)->total[0] == 0);
/* Recovery preserves totals and the flow baselines, preventing replay. */
reset_sampler("test_overrun");
assert(!a->ready && !a->warm && flows.count > 0);
feed(AF_INET, "192.168.0.100", "8.8.8.8", "8.8.8.8", 1, 220, 330, false);
assert(ha->total[0] + ha->total[1] == before + 500);
/* Collection is scheduled even when no browser has ever requested data. */
uloop_init();
sample_timer(&timer);
assert(timer.pending);
uloop_timeout_cancel(&timer);
reset_sampler("test_end");
uloop_done();
unlink(STATE_FILE);
puts("traffic: NAT, IPv6, rolling rates, exact totals, restart recovery, IP reassignment and continuous sampling passed");
return 0;
}
+62
View File
@@ -0,0 +1,62 @@
/* SPDX-License-Identifier: Apache-2.0 */
#include <assert.h>
#include "../src/webprobe.c"
void rpc_luci_dhcp4_clients(void (*add)(const struct in_addr *, const unsigned char *)) {}
static struct web_client *add(const char *ip, const unsigned char *mac)
{
struct in_addr address;
struct web_client *client;
assert(inet_pton(AF_INET, ip, &address) == 1);
web_add_client(&address, mac);
return avl_find_element(&web_clients, &address, client, avl);
}
int main(void)
{
unsigned char mac[6] = {2, 1, 2, 3, 4, 5}, other[6] = {2, 6, 7, 8, 9, 10};
uloop_init();
avl_init(&web_clients, web_compare, false, NULL);
for (int i = 0; i < WEB_CONCURRENCY; i++) web_jobs[i].fd.fd = -1;
web_pump.cb = web_run;
web_inventory.cb = web_refresh;
assert(!add("127.0.0.1", mac));
assert(!add("0.0.0.0", mac));
assert(!add("224.0.0.1", mac));
struct web_client *c = add("192.0.2.1", mac);
assert(c && web_clients.count == 1 && !c->ready);
assert(add("192.0.2.1", mac) == c && web_clients.count == 1);
/* Publish only a complete round, with exact port-to-bit correspondence. */
c->scanning = true;
c->next_port = c->pending = WEB_PORT_COUNT;
for (int i = 0; i < WEB_PORT_COUNT; i++) {
web_jobs[0].client = c;
web_jobs[0].port = i;
web_finish(&web_jobs[0], i == 0 || i == 5);
assert(c->ready == (i == WEB_PORT_COUNT - 1));
}
assert(c->open_ports == 33 && c->next_scan > web_now());
/* Refresh failure must remove links which succeeded in a previous round. */
c->scanning = true; c->checking = 0; c->pending = 1;
web_jobs[0].client = c;
web_jobs[0].timeout.cb = web_timeout;
web_timeout(&web_jobs[0].timeout);
assert(c->open_ports == 0 && c->ready);
/* IP reassignment invalidates cached results and closes pending sockets. */
c->open_ports = 15;
web_jobs[0].client = c;
web_jobs[0].fd.fd = socket(AF_INET, SOCK_STREAM, 0);
int fd = web_jobs[0].fd.fd;
add("192.0.2.1", other);
assert(!c->ready && !c->open_ports && !web_jobs[0].client);
assert(close(fd) == -1 && errno == EBADF);
/* A lease disappearing removes its cached entry and pending jobs. */
web_refresh(&web_inventory);
assert(!web_clients.count);
uloop_timeout_cancel(&web_inventory);
uloop_timeout_cancel(&web_pump);
uloop_done();
puts("webprobe: bounded targets, round publication, timeout, IP reassignment and lease removal passed");
return 0;
}
+36
View File
@@ -0,0 +1,36 @@
# DHCP client web links
The `luci.client-web get` RPC returns cached TCP reachability for current DHCPv4
leases, keyed by IPv4 address and tagged with the client's MAC. It accepts no
scan targets or ports from the caller. No scanning is triggered by reading it.
The collector starts five seconds after rpcd loads, refreshes the lease inventory
every 30 seconds, and checks each client again five minutes after its previous
round completes. At most two nonblocking TCP connects run concurrently, with
a 750 ms timeout each and a 250 ms delay between queue refills. Idle queues
sleep until the next scheduled probe. Earliest-due clients are scheduled first so large lease
lists cannot starve later addresses. The cache holds at most 1024 clients, is
kept in memory only, and is cleared on lease removal or MAC reassignment. Results
are published after the entire round completes; links from unsuccessful rounds
are removed and results older than ten minutes are not exposed as open ports.
The frontend uses exactly one link, in this order:
- HTTP: 80, 8080, 5666
- HTTPS: 443, 4430, 5667
Default ports are omitted from URLs. Links display only the underlined IP address, with the full URL in the link target
and open in a new tab with `noopener noreferrer`. Unknown or unavailable clients
remain plain text. Results with a different MAC from the displayed lease are
ignored. The regular overview refresh reads the cache without blocking on scans.
An open TCP port is a reachability result, not an HTTP response or TLS certificate
validation. The protocol is selected by the fixed port mapping above. No HTTP
requests or TLS handshakes are sent by the scanner.
Compile `webprobe.c` as a standalone target-native test with the staging headers
and libraries (`-lubus -lubox`). It covers target validation, deduplication,
complete-round publication, timeout results, MAC changes and lease removal.
`traffic-ui.js` also checks URL priority, schemes, ports and stale-MAC rejection.
On a live router, compare the cached port list with TCP reachability and verify
the single rendered IP link, URL, target and continued status polling.