mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-01 03:15:13 +08:00
🎈 Sync 2026-09-16 00:45:17
This commit is contained in:
1 parent
d5fe29717e
commit
afb665d9c0
10 files changed
+3785
No files matched your search
@@ -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.
|
||||
@@ -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;});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user