🎈 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

+55
View File
@@ -0,0 +1,55 @@
#
# Copyright (C) 2016-2017 Jo-Philipp Wich <jo@mein.io>
#
# Licensed under the Apache License, Version 2.0.
#
include $(TOPDIR)/rules.mk
PKG_NAME:=rpcd-mod-luci
PKG_VERSION:=20240305
PKG_RELEASE:=1
PKG_MAINTAINER:=Jo-Philipp Wich <jo@mein.io>
PKG_LICENSE:=Apache-2.0
PKG_BUILD_PARALLEL:=1
PKG_BUILD_DEPENDS:=iwinfo
include $(INCLUDE_DIR)/package.mk
include $(INCLUDE_DIR)/cmake.mk
TARGET_CFLAGS += \
-I$(STAGING_DIR)/usr/include/libnl-tiny \
-I$(STAGING_DIR)/usr/include \
CMAKE_OPTIONS += \
-DLIBNL_LIBS=-lnl-tiny \
define Build/Prepare
$(INSTALL_DIR) $(PKG_BUILD_DIR)
$(CP) ./src/* $(PKG_BUILD_DIR)/
endef
define Package/rpcd-mod-luci
SECTION:=libs
CATEGORY:=Libraries
TITLE:=Rapid reverse DNS rpcd module
DEPENDS:=+rpcd +libubox +libubus +libnl-tiny
endef
define Package/rpcd-mod-luci/description
Provides LuCI backend ubus RPC operations.
endef
define Package/rpcd-mod-luci/install
$(INSTALL_DIR) $(1)/usr/lib/rpcd
$(INSTALL_BIN) $(PKG_BUILD_DIR)/luci.so $(1)/usr/lib/rpcd/
endef
define Package/rpcd-mod-luci/postinst
#!/bin/sh
[ -n "$$IPKG_INSTROOT" ] || /etc/init.d/rpcd reload
endef
$(eval $(call BuildPackage,rpcd-mod-luci))
+23
View File
@@ -0,0 +1,23 @@
cmake_minimum_required(VERSION 3.31)
PROJECT(rpcd-mod-luci C)
ADD_DEFINITIONS(-Os -Wall -Werror --std=gnu99 -g3 -Wmissing-declarations)
SET(CMAKE_SHARED_LIBRARY_LINK_C_FLAGS "")
IF(APPLE)
INCLUDE_DIRECTORIES(/opt/local/include)
LINK_DIRECTORIES(/opt/local/lib)
ENDIF()
IF (NOT DEFINED LIBNL_LIBS)
FIND_LIBRARY(libnl NAMES libnl-3 libnl nl-3 nl)
SET(LIBNL_LIBS ${libnl})
ENDIF()
ADD_LIBRARY(rpcd-mod-luci MODULE luci.c traffic.c webprobe.c)
TARGET_LINK_LIBRARIES(rpcd-mod-luci ubox ubus uci dl ${LIBNL_LIBS})
SET_TARGET_PROPERTIES(rpcd-mod-luci PROPERTIES OUTPUT_NAME luci PREFIX "")
INSTALL(TARGETS rpcd-mod-luci LIBRARY DESTINATION lib)
File diff suppressed because it is too large. Load diff
+781
View File
@@ -0,0 +1,781 @@
/* SPDX-License-Identifier: Apache-2.0 */
/* Read-only conntrack accounting, including ECM/NSS synchronized counters. */
#include <arpa/inet.h>
#include <errno.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <unistd.h>
#include <time.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <netinet/ether.h>
#include <sys/socket.h>
#include <linux/netlink.h>
#include <linux/netfilter/nfnetlink.h>
#include <linux/netfilter/nfnetlink_conntrack.h>
#include <libubox/avl.h>
#include <libubox/uloop.h>
#include <libubus.h>
#define SAMPLE_MS 2000
#define WINDOW_SAMPLES 3
#define ADDRESS_IDLE_MS (24ULL * 60 * 60 * 1000)
#define MAX_HOSTS 4096
#ifndef STATE_FILE
#define STATE_FILE "/tmp/luci-client-traffic.state"
#endif
#define MAX_CLIENTS 8192
#define MAX_REQUEST 1024
#define MAX_FLOWS 32768
struct address { uint8_t family, bytes[16]; };
struct flow_key {
struct address src, dst, reply_src;
uint32_t id;
uint16_t sport, dport, zone;
uint8_t protocol;
};
struct host {
struct avl_node avl;
unsigned char mac[6];
uint64_t total[2];
uint32_t connections, pending_connections;
bool available;
};
struct flow {
struct avl_node avl;
struct flow_key key;
uint64_t bytes[2];
unsigned char owner[2][6];
uint32_t generation;
bool destroyed;
};
struct client {
struct avl_node avl;
struct address address;
unsigned char mac[6];
uint64_t bytes[2], rate[2], touched;
uint64_t history[WINDOW_SAMPLES][2], duration[WINDOW_SAMPLES];
unsigned int next_sample;
bool warm, ready, missing;
};
static struct avl_tree clients, flows, hosts;
static struct uloop_fd ct_fd = { .fd = -1 };
static struct uloop_timeout timer;
static uint32_t sequence, generation;
static uint64_t dump_started, sampled, started;
static bool incomplete;
static bool dumping, initialized;
static const char *failure = "warming_up";
static struct blob_buf result;
int rpc_luci_traffic_init(struct ubus_context *ctx);
void rpc_luci_traffic_discover(void (*add)(int, const void *, const unsigned char *));
static void save_state(void);
static void load_state(void);
static uint64_t now_ms(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (uint64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
}
static int address_cmp(const void *a, const void *b, void *priv)
{
return memcmp(a, b, sizeof(struct address));
}
static int flow_cmp(const void *a, const void *b, void *priv)
{
return memcmp(a, b, sizeof(struct flow_key));
}
static bool parse_address(const char *text, struct address *a)
{
memset(a, 0, sizeof(*a));
if (inet_pton(AF_INET, text, a->bytes) == 1) {
a->family = AF_INET;
return true;
}
if (inet_pton(AF_INET6, text, a->bytes) == 1) {
a->family = AF_INET6;
return true;
}
return false;
}
static int mac_cmp(const void *a, const void *b, void *priv)
{
return memcmp(a, b, 6);
}
static struct host *get_host(const unsigned char *mac, bool create)
{
static const unsigned char zero[6];
struct host *h;
if (!memcmp(mac, zero, 6) || (mac[0] & 1))
return NULL;
h = avl_find_element(&hosts, mac, h, avl);
if (!h && create) {
if (hosts.count >= MAX_HOSTS || !(h = calloc(1, sizeof(*h)))) {
incomplete = true;
return NULL;
}
memcpy(h->mac, mac, 6);
h->avl.key = h->mac;
avl_insert(&hosts, &h->avl);
}
return h;
}
static void bind_client(int family, const void *bytes, const unsigned char *mac)
{
struct address a = { .family = family };
struct client *c;
if ((family != AF_INET && family != AF_INET6) || !get_host(mac, true))
return;
memcpy(a.bytes, bytes, family == AF_INET ? 4 : 16);
c = avl_find_element(&clients, &a, c, avl);
if (!c) {
if (clients.count >= MAX_CLIENTS || !(c = calloc(1, sizeof(*c)))) {
incomplete = true;
return;
}
c->address = a;
c->avl.key = &c->address;
avl_insert(&clients, &c->avl);
}
if (memcmp(c->mac, mac, 6)) {
memcpy(c->mac, mac, 6);
c->warm = c->ready = false;
memset(c->bytes, 0, sizeof(c->bytes));
memset(c->history, 0, sizeof(c->history));
memset(c->duration, 0, sizeof(c->duration));
c->next_sample = 0;
}
c->touched = now_ms();
}
/* Apply one final binding per address: a stale neighbour and a current DHCP
* lease must not reset the same client's rate baseline twice per scan. */
struct binding { struct avl_node avl; struct address address; unsigned char mac[6]; };
static struct avl_tree bindings;
static void stage_binding(int family, const void *bytes, const unsigned char *mac)
{
struct address address = { .family = family };
struct binding *b;
if (family != AF_INET && family != AF_INET6) return;
memcpy(address.bytes, bytes, family == AF_INET ? 4 : 16);
b = avl_find_element(&bindings, &address, b, avl);
if (!b) {
if (bindings.count >= MAX_CLIENTS || !(b = calloc(1, sizeof(*b)))) {
incomplete = true;
return;
}
b->address = address;
b->avl.key = &b->address;
avl_insert(&bindings, &b->avl);
}
memcpy(b->mac, mac, 6);
}
static void discover_clients(void)
{
struct binding *b, *next;
avl_init(&bindings, address_cmp, false, NULL);
rpc_luci_traffic_discover(stage_binding);
avl_for_each_element_safe(&bindings, b, avl, next) {
bind_client(b->address.family, b->address.bytes, b->mac);
avl_delete(&bindings, &b->avl);
free(b);
}
}
static void clear_flows(void)
{
struct flow *f, *tmp;
avl_for_each_element_safe(&flows, f, avl, tmp) {
avl_delete(&flows, &f->avl);
free(f);
}
}
static void reset_sampler(const char *reason)
{
struct client *c;
if (ct_fd.fd >= 0) {
uloop_fd_delete(&ct_fd);
close(ct_fd.fd);
ct_fd.fd = -1;
}
incomplete = true;
avl_for_each_element(&clients, c, avl) {
c->warm = c->ready = false;
c->bytes[0] = c->bytes[1] = 0;
memset(c->history, 0, sizeof(c->history));
memset(c->duration, 0, sizeof(c->duration));
c->next_sample = 0;
}
dumping = false;
sampled = 0;
failure = reason;
}
/* A complete checkpoint lives in tmpfs, never on flash. Keep flow baselines
* together with totals so an rpcd reload cannot count live connections twice. */
struct state_header {
char magic[8], boot[40];
uint64_t started;
uint32_t hosts, clients, flows, generation, incomplete;
};
struct host_record { unsigned char mac[6]; uint64_t total[2]; bool available; };
struct client_record { struct address address; unsigned char mac[6]; uint64_t touched; };
struct flow_record {
struct flow_key key;
uint64_t bytes[2];
unsigned char owner[2][6];
uint32_t generation;
bool destroyed;
};
static void boot_id(char *out, size_t len)
{
FILE *f = fopen("/proc/sys/kernel/random/boot_id", "r");
if (f) { if (!fgets(out, len, f)) out[0] = 0; fclose(f); }
}
static void save_state(void)
{
struct state_header header = { .magic = "LCTRAF1", .started = started,
.hosts = hosts.count, .clients = clients.count, .flows = flows.count,
.generation = generation, .incomplete = incomplete };
struct host *h;
struct client *c;
struct flow *flow;
bool ok = true;
boot_id(header.boot, sizeof(header.boot));
if (!header.boot[0]) return;
int fd = open(STATE_FILE ".new", O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW | O_CLOEXEC, 0600);
if (fd < 0) return;
FILE *f = fdopen(fd, "wb");
if (!f) { close(fd); return; }
ok = fwrite(&header, sizeof(header), 1, f) == 1;
avl_for_each_element(&hosts, h, avl) {
struct host_record r = {0};
memcpy(r.mac, h->mac, 6);
memcpy(r.total, h->total, sizeof(r.total));
r.available = h->available;
ok &= fwrite(&r, sizeof(r), 1, f) == 1;
}
avl_for_each_element(&clients, c, avl) {
struct client_record r = { .address = c->address, .touched = c->touched };
memcpy(r.mac, c->mac, 6);
ok &= fwrite(&r, sizeof(r), 1, f) == 1;
}
avl_for_each_element(&flows, flow, avl) {
struct flow_record r = { .key = flow->key, .generation = flow->generation,
.destroyed = flow->destroyed };
memcpy(r.bytes, flow->bytes, sizeof(r.bytes));
memcpy(r.owner, flow->owner, sizeof(r.owner));
ok &= fwrite(&r, sizeof(r), 1, f) == 1;
}
if (fclose(f)) ok = false;
if (ok) rename(STATE_FILE ".new", STATE_FILE);
else unlink(STATE_FILE ".new");
}
static void load_state(void)
{
struct state_header header;
char boot[40] = {0};
struct stat st;
int fd = open(STATE_FILE, O_RDONLY | O_NOFOLLOW | O_CLOEXEC);
if (fd < 0) return;
FILE *f = fdopen(fd, "rb");
if (!f) { close(fd); return; }
boot_id(boot, sizeof(boot));
if (fstat(fd, &st) || !S_ISREG(st.st_mode) || st.st_uid != geteuid() ||
fread(&header, sizeof(header), 1, f) != 1 ||
memcmp(header.magic, "LCTRAF1", 8) || !boot[0] || memcmp(header.boot, boot, sizeof(boot)) ||
header.hosts > MAX_HOSTS || header.clients > MAX_CLIENTS || header.flows > MAX_FLOWS ||
header.started > now_ms() || st.st_size != sizeof(header) +
header.hosts * sizeof(struct host_record) + header.clients * sizeof(struct client_record) +
header.flows * sizeof(struct flow_record)) goto out;
for (uint32_t i = 0; i < header.hosts; i++) {
struct host_record r;
struct host *h;
if (fread(&r, sizeof(r), 1, f) != 1 || !(h = get_host(r.mac, true))) goto fail;
memcpy(h->total, r.total, sizeof(h->total));
h->available = r.available;
}
for (uint32_t i = 0; i < header.clients; i++) {
struct client_record r;
struct client *c;
if (fread(&r, sizeof(r), 1, f) != 1) goto fail;
bind_client(r.address.family, r.address.bytes, r.mac);
c = avl_find_element(&clients, &r.address, c, avl);
if (!c) goto fail;
c->touched = r.touched;
}
for (uint32_t i = 0; i < header.flows; i++) {
struct flow_record r;
struct flow *flow;
if (fread(&r, sizeof(r), 1, f) != 1 || !(flow = calloc(1, sizeof(*flow)))) goto fail;
flow->key = r.key;
flow->avl.key = &flow->key;
memcpy(flow->bytes, r.bytes, sizeof(flow->bytes));
memcpy(flow->owner, r.owner, sizeof(flow->owner));
flow->generation = r.generation;
flow->destroyed = r.destroyed;
if (avl_insert(&flows, &flow->avl)) { free(flow); goto fail; }
}
started = header.started;
generation = header.generation;
/* Connections ending while rpcd was stopped cannot be recovered. */
incomplete = true;
goto out;
fail:;
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); }
incomplete = true;
out:
fclose(f);
}
/* All attributes come from the kernel, but validate lengths before reading. */
static struct nlattr *attr_find(void *data, size_t len, unsigned int type)
{
struct nlattr *a = data;
while (len >= sizeof(*a) && a->nla_len >= sizeof(*a) && a->nla_len <= len) {
if ((a->nla_type & NLA_TYPE_MASK) == type)
return a;
size_t step = NLA_ALIGN(a->nla_len);
if (step > len)
break;
len -= step;
a = (void *)((char *)a + step);
}
return NULL;
}
static struct nlattr *nested(struct nlattr *a, unsigned int type)
{
return a ? attr_find((char *)a + NLA_HDRLEN, a->nla_len - NLA_HDRLEN, type) : NULL;
}
static bool attr_copy(struct nlattr *a, void *out, size_t len)
{
if (!a || a->nla_len < NLA_HDRLEN + len)
return false;
memcpy(out, (char *)a + NLA_HDRLEN, len);
return true;
}
static bool tuple_address(struct nlattr *tuple, unsigned int family, bool source, struct address *addr)
{
struct nlattr *ip = nested(tuple, CTA_TUPLE_IP);
unsigned int type = family == AF_INET ? (source ? CTA_IP_V4_SRC : CTA_IP_V4_DST) :
(source ? CTA_IP_V6_SRC : CTA_IP_V6_DST);
addr->family = family;
return attr_copy(nested(ip, type), addr->bytes, family == AF_INET ? 4 : 16);
}
static bool counter_value(struct nlattr *a, uint64_t *value)
{
unsigned char bytes[8];
if (!attr_copy(nested(a, CTA_COUNTERS_BYTES), bytes, sizeof(bytes)))
return false;
*value = 0;
for (int i = 0; i < 8; i++)
*value = (*value << 8) | bytes[i];
return true;
}
static void add_bytes(struct client *c, const unsigned char *owner, uint64_t up, uint64_t down, bool count)
{
struct host *h = get_host(owner, false);
if (h) h->available = true;
if (count && h) {
h->total[0] += up;
h->total[1] += down;
}
if (c && c->warm && !memcmp(c->mac, owner, 6)) {
c->bytes[0] += up;
c->bytes[1] += down;
}
}
/* Count dump records independently of byte accounting and flow tombstones. */
static void count_connection(struct nlmsghdr *nlh)
{
struct nfgenmsg *msg = NLMSG_DATA(nlh);
struct address orig_src = {0}, reply_src = {0};
if (nlh->nlmsg_len < NLMSG_LENGTH(sizeof(*msg)) ||
(msg->nfgen_family != AF_INET && msg->nfgen_family != AF_INET6))
return;
void *data = (char *)msg + NLMSG_ALIGN(sizeof(*msg));
size_t len = nlh->nlmsg_len - NLMSG_LENGTH(sizeof(*msg));
if (!tuple_address(attr_find(data, len, CTA_TUPLE_ORIG), msg->nfgen_family, true, &orig_src) ||
!tuple_address(attr_find(data, len, CTA_TUPLE_REPLY), msg->nfgen_family, true, &reply_src))
return;
struct client *src = avl_find_element(&clients, &orig_src, src, avl);
struct client *dst = avl_find_element(&clients, &reply_src, dst, avl);
struct host *a = src ? get_host(src->mac, false) : NULL;
struct host *b = dst ? get_host(dst->mac, false) : NULL;
if (a) a->pending_connections++;
/* Hairpin and dual-address connections count once for the same MAC. */
if (b && b != a) b->pending_connections++;
}
static bool process_flow(struct nlmsghdr *nlh)
{
struct nfgenmsg *msg = NLMSG_DATA(nlh);
struct flow_key key = {0};
struct flow *f;
struct client *src, *dst;
uint64_t bytes[2], delta[2] = {0};
bool known;
bool destroyed = (nlh->nlmsg_type & 0xff) == IPCTNL_MSG_CT_DELETE;
if (nlh->nlmsg_len < NLMSG_LENGTH(sizeof(*msg)) ||
(msg->nfgen_family != AF_INET && msg->nfgen_family != AF_INET6))
return true;
void *data = (char *)msg + NLMSG_ALIGN(sizeof(*msg));
size_t len = nlh->nlmsg_len - NLMSG_LENGTH(sizeof(*msg));
struct nlattr *orig = attr_find(data, len, CTA_TUPLE_ORIG);
struct nlattr *reply = attr_find(data, len, CTA_TUPLE_REPLY);
struct nlattr *proto = nested(orig, CTA_TUPLE_PROTO);
if (!tuple_address(orig, msg->nfgen_family, true, &key.src) ||
!tuple_address(orig, msg->nfgen_family, false, &key.dst) ||
!tuple_address(reply, msg->nfgen_family, true, &key.reply_src) ||
!attr_copy(nested(proto, CTA_PROTO_NUM), &key.protocol, 1) ||
!attr_copy(attr_find(data, len, CTA_ID), &key.id, 4))
return true;
attr_copy(nested(proto, CTA_PROTO_SRC_PORT), &key.sport, 2);
attr_copy(nested(proto, CTA_PROTO_DST_PORT), &key.dport, 2);
attr_copy(attr_find(data, len, CTA_ZONE), &key.zone, 2);
src = avl_find_element(&clients, &key.src, src, avl);
/* reply.src identifies the internal destination of a DNAT connection. */
dst = avl_find_element(&clients, &key.reply_src, dst, avl);
f = avl_find_element(&flows, &key, f, avl);
known = f != NULL;
if (!src && !dst && !f)
return true;
if (!counter_value(attr_find(data, len, CTA_COUNTERS_ORIG), &bytes[0]) ||
!counter_value(attr_find(data, len, CTA_COUNTERS_REPLY), &bytes[1])) {
if (src) src->missing = true;
if (dst) dst->missing = true;
incomplete = true;
return true;
}
if (!f) {
if (flows.count >= MAX_FLOWS)
return false;
f = calloc(1, sizeof(*f));
if (!f)
return false;
f->key = key;
if (src) memcpy(f->owner[0], src->mac, 6);
if (dst) memcpy(f->owner[1], dst->mac, 6);
f->avl.key = &f->key;
avl_insert(&flows, &f->avl);
}
/* A dump record can be queued before a destroy notification is delivered.
* Keep tombstones for two rounds to avoid counting that old record twice. */
if (f->destroyed)
return true;
for (int i = 0; i < 2; i++) {
/* A counter reset starts a new baseline; never subtract unsigned values. */
if (bytes[i] >= f->bytes[i])
delta[i] = bytes[i] - f->bytes[i];
f->bytes[i] = bytes[i];
}
add_bytes(src, f->owner[0], delta[0], delta[1], known || (src && src->warm));
if (memcmp(f->owner[0], f->owner[1], 6))
add_bytes(dst, f->owner[1], delta[1], delta[0], known || (dst && dst->warm));
f->generation = generation;
if (destroyed)
f->destroyed = true;
return true;
}
/* Weight by actual elapsed time, not an average of rounded sample rates. */
static void update_rate(struct client *c, uint64_t elapsed)
{
if (!c->ready) {
memset(c->history, 0, sizeof(c->history));
memset(c->duration, 0, sizeof(c->duration));
c->next_sample = 0;
} else {
memcpy(c->history[c->next_sample], c->bytes, sizeof(c->bytes));
c->duration[c->next_sample] = elapsed;
c->next_sample = (c->next_sample + 1) % WINDOW_SAMPLES;
}
uint64_t duration = 0, bytes[2] = {0};
for (int j = 0; j < WINDOW_SAMPLES; j++) {
duration += c->duration[j];
bytes[0] += c->history[j][0];
bytes[1] += c->history[j][1];
}
for (int i = 0; i < 2; i++) {
c->rate[i] = duration ? (uint64_t)((double)bytes[i] * 1000 / duration) : 0;
c->bytes[i] = 0;
}
}
static void finish_dump(void)
{
struct host *h;
avl_for_each_element(&hosts, h, avl)
h->connections = h->pending_connections;
uint64_t now = now_ms(), elapsed = now - sampled;
struct client *c;
struct flow *f, *tmp;
avl_for_each_element(&clients, c, avl) {
c->ready = c->warm && sampled && elapsed && !c->missing;
update_rate(c, elapsed);
c->warm = true;
c->missing = false;
}
avl_for_each_element_safe(&flows, f, avl, tmp) {
if ((uint32_t)(generation - f->generation) > 1) {
avl_delete(&flows, &f->avl);
free(f);
}
}
sampled = now;
dumping = false;
failure = NULL;
save_state();
}
static void receive_conntrack(struct uloop_fd *fd, unsigned int events)
{
/* Bound each callback so a large table cannot starve other rpcd requests. */
for (int batch = 0; batch < 32; batch++) {
char buf[65536];
struct sockaddr_nl sender;
struct iovec iov = { .iov_base = buf, .iov_len = sizeof(buf) };
struct msghdr msg = { .msg_name = &sender, .msg_namelen = sizeof(sender),
.msg_iov = &iov, .msg_iovlen = 1 };
ssize_t len = recvmsg(fd->fd, &msg, MSG_DONTWAIT);
if (len < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR)
return;
reset_sampler("netlink_overrun");
return;
}
if (!len || (msg.msg_flags & MSG_TRUNC)) {
reset_sampler("truncated_dump");
return;
}
if (sender.nl_pid)
continue;
struct nlmsghdr *nlh;
for (nlh = (void *)buf; NLMSG_OK(nlh, len); nlh = NLMSG_NEXT(nlh, len)) {
bool response = dumping && nlh->nlmsg_seq == sequence;
if (response && (nlh->nlmsg_flags & NLM_F_DUMP_INTR)) {
reset_sampler("interrupted_dump");
return;
}
if (nlh->nlmsg_type == NLMSG_ERROR) {
reset_sampler("netlink_error");
return;
}
if (nlh->nlmsg_type == NLMSG_DONE) {
if (response) finish_dump();
continue;
}
if ((nlh->nlmsg_type >> 8) != NFNL_SUBSYS_CTNETLINK)
continue;
if (response && (nlh->nlmsg_type & 0xff) == IPCTNL_MSG_CT_NEW)
count_connection(nlh);
if (response || (nlh->nlmsg_type & 0xff) == IPCTNL_MSG_CT_DELETE) {
if (!process_flow(nlh)) {
reset_sampler("flow_limit");
/* Drop baselines only at capacity, allowing a later smaller dump
* to recover. Cold clients will establish a fresh baseline. */
clear_flows();
return;
}
}
}
}
}
static bool open_conntrack(void)
{
struct sockaddr_nl addr = { .nl_family = AF_NETLINK,
.nl_groups = 1 << (NFNLGRP_CONNTRACK_DESTROY - 1) };
int size = 1024 * 1024;
ct_fd.fd = socket(AF_NETLINK, SOCK_RAW | SOCK_NONBLOCK | SOCK_CLOEXEC, NETLINK_NETFILTER);
if (ct_fd.fd < 0)
return false;
setsockopt(ct_fd.fd, SOL_SOCKET, SO_RCVBUF, &size, sizeof(size));
if (bind(ct_fd.fd, (void *)&addr, sizeof(addr)) < 0) {
close(ct_fd.fd);
ct_fd.fd = -1;
return false;
}
ct_fd.cb = receive_conntrack;
if (uloop_fd_add(&ct_fd, ULOOP_READ) < 0) {
close(ct_fd.fd);
ct_fd.fd = -1;
return false;
}
return true;
}
static void sample_timer(struct uloop_timeout *t)
{
uint64_t now = now_ms();
struct client *c, *tmp;
avl_for_each_element_safe(&clients, c, avl, tmp) {
if (now - c->touched > ADDRESS_IDLE_MS) {
avl_delete(&clients, &c->avl);
free(c);
}
}
uloop_timeout_set(&timer, SAMPLE_MS);
if (dumping) {
if (now - dump_started > 10000)
reset_sampler("dump_timeout");
return;
}
discover_clients();
FILE *acct = fopen("/proc/sys/net/netfilter/nf_conntrack_acct", "r");
int enabled = 0;
if (acct) { enabled = fgetc(acct) == '1'; fclose(acct); }
if (!enabled) {
reset_sampler("accounting_disabled");
uloop_timeout_set(&timer, 10000);
return;
}
if (ct_fd.fd < 0 && !open_conntrack()) {
failure = "netlink_unavailable";
uloop_timeout_set(&timer, 10000);
return;
}
struct { struct nlmsghdr nlh; struct nfgenmsg nfg; } request = {
.nlh = { .nlmsg_len = NLMSG_LENGTH(sizeof(struct nfgenmsg)),
.nlmsg_type = (NFNL_SUBSYS_CTNETLINK << 8) | IPCTNL_MSG_CT_GET,
.nlmsg_flags = NLM_F_REQUEST | NLM_F_DUMP, .nlmsg_seq = ++sequence },
.nfg = { .nfgen_family = AF_UNSPEC, .version = NFNETLINK_V0 }
};
struct sockaddr_nl kernel = { .nl_family = AF_NETLINK };
/* Never use CT_GET_CTRZERO: other accounting consumers keep their counters. */
if (sendto(ct_fd.fd, &request, request.nlh.nlmsg_len, MSG_DONTWAIT,
(void *)&kernel, sizeof(kernel)) < 0) {
reset_sampler("send_failed");
return;
}
struct host *h;
avl_for_each_element(&hosts, h, avl)
h->pending_connections = 0;
generation++;
dumping = true;
dump_started = now;
}
static const struct blobmsg_policy policy[] = {
{ .name = "addresses", .type = BLOBMSG_TYPE_ARRAY }
};
static int get_rates(struct ubus_context *ctx, struct ubus_object *obj,
struct ubus_request_data *req, const char *method, struct blob_attr *msg)
{
struct blob_attr *tb[1], *a;
struct address address;
struct client *c;
uint64_t now = now_ms();
int rem, count = 0;
if (!msg)
return UBUS_STATUS_INVALID_ARGUMENT;
blobmsg_parse(policy, 1, tb, blob_data(msg), blob_len(msg));
if (!tb[0])
return UBUS_STATUS_INVALID_ARGUMENT;
blobmsg_for_each_attr(a, tb[0], rem) {
if (++count > MAX_REQUEST || blobmsg_type(a) != BLOBMSG_TYPE_STRING ||
!parse_address(blobmsg_get_string(a), &address))
return UBUS_STATUS_INVALID_ARGUMENT;
}
blob_buf_init(&result, 0);
blobmsg_add_string(&result, "source", "conntrack");
blobmsg_add_u64(&result, "started_ms", started);
blobmsg_add_u8(&result, "incomplete", incomplete);
blobmsg_add_u32(&result, "interval_ms", SAMPLE_MS);
blobmsg_add_u32(&result, "window_ms", SAMPLE_MS * WINDOW_SAMPLES);
blobmsg_add_u32(&result, "age_ms", sampled ? now - sampled : 0);
if (failure)
blobmsg_add_string(&result, "status", failure);
void *table = blobmsg_open_table(&result, "rates");
blobmsg_for_each_attr(a, tb[0], rem) {
parse_address(blobmsg_get_string(a), &address);
c = avl_find_element(&clients, &address, c, avl);
void *entry = blobmsg_open_table(&result, blobmsg_get_string(a));
bool ready = c && c->ready && sampled && now - sampled < 3 * SAMPLE_MS;
blobmsg_add_u8(&result, "ready", ready);
struct host *h = c ? get_host(c->mac, false) : NULL;
if (h) {
char mac[18];
snprintf(mac, sizeof(mac), "%02X:%02X:%02X:%02X:%02X:%02X",
h->mac[0], h->mac[1], h->mac[2], h->mac[3], h->mac[4], h->mac[5]);
blobmsg_add_string(&result, "mac", mac);
}
if (ready) {
blobmsg_add_u64(&result, "upload", c->rate[0]);
blobmsg_add_u64(&result, "download", c->rate[1]);
}
blobmsg_close_table(&result, entry);
}
blobmsg_close_table(&result, table);
void *totals = blobmsg_open_table(&result, "totals");
struct host *host;
avl_for_each_element(&hosts, host, avl) {
if (!host->available && !sampled) continue;
char mac[18];
snprintf(mac, sizeof(mac), "%02X:%02X:%02X:%02X:%02X:%02X",
host->mac[0], host->mac[1], host->mac[2], host->mac[3], host->mac[4], host->mac[5]);
blobmsg_add_u64(&result, mac, host->total[0] + host->total[1]);
}
blobmsg_close_table(&result, totals);
void *connections = blobmsg_open_table(&result, "connections");
if (sampled && !failure && now - sampled < 3 * SAMPLE_MS) {
avl_for_each_element(&hosts, host, avl) {
char mac[18];
snprintf(mac, sizeof(mac), "%02X:%02X:%02X:%02X:%02X:%02X",
host->mac[0], host->mac[1], host->mac[2], host->mac[3], host->mac[4], host->mac[5]);
blobmsg_add_u32(&result, mac, host->connections);
}
}
blobmsg_close_table(&result, connections);
ubus_send_reply(ctx, req, result.head);
return 0;
}
int rpc_luci_traffic_init(struct ubus_context *ctx)
{
static const struct ubus_method methods[] = { UBUS_METHOD("get", get_rates, policy) };
static struct ubus_object_type type = UBUS_OBJECT_TYPE("luci-client-rates", methods);
static struct ubus_object obj = { .name = "luci.client-rates", .type = &type,
.methods = methods, .n_methods = 1 };
if (!initialized) {
avl_init(&clients, address_cmp, false, NULL);
avl_init(&flows, flow_cmp, false, NULL);
avl_init(&hosts, mac_cmp, false, NULL);
started = now_ms();
load_state();
timer.cb = sample_timer;
initialized = true;
uloop_timeout_set(&timer, 1);
}
return ubus_add_object(ctx, &obj);
}
+260
View File
@@ -0,0 +1,260 @@
/* SPDX-License-Identifier: Apache-2.0 */
/* Bounded, asynchronous TCP reachability checks for current DHCPv4 leases. */
#include <arpa/inet.h>
#include <errno.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <unistd.h>
#include <time.h>
#include <sys/socket.h>
#include <libubox/avl.h>
#include <libubox/uloop.h>
#include <libubus.h>
#define WEB_MAX_CLIENTS 1024
#define WEB_CONCURRENCY 2
#define WEB_TIMEOUT_MS 750
#define WEB_INTERVAL_MS 300000
#define WEB_LEASE_REFRESH_MS 30000
static const uint16_t web_ports[] = { 80, 8080, 5666, 443, 4430, 5667 };
#define WEB_PORT_COUNT (sizeof(web_ports) / sizeof(web_ports[0]))
struct web_client {
struct avl_node avl;
struct in_addr address;
unsigned char mac[6];
uint64_t next_scan, checked;
uint32_t generation;
unsigned int next_port, pending, open_ports, checking;
bool scanning, ready;
};
struct web_job {
struct uloop_fd fd;
struct uloop_timeout timeout;
struct web_client *client;
unsigned int port;
};
static struct avl_tree web_clients;
static struct web_job web_jobs[WEB_CONCURRENCY];
static struct uloop_timeout web_inventory, web_pump;
static uint32_t web_generation;
static bool web_initialized;
static struct blob_buf web_result;
int rpc_luci_webprobe_init(struct ubus_context *ctx);
void rpc_luci_dhcp4_clients(void (*add)(const struct in_addr *, const unsigned char *));
static uint64_t web_now(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (uint64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
}
static int web_compare(const void *a, const void *b, void *priv)
{
return memcmp(a, b, sizeof(struct in_addr));
}
static void web_close(struct web_job *job)
{
uloop_timeout_cancel(&job->timeout);
if (job->fd.fd >= 0) {
uloop_fd_delete(&job->fd);
close(job->fd.fd);
job->fd.fd = -1;
}
job->client = NULL;
}
static void web_cancel_client(struct web_client *client)
{
for (int i = 0; i < WEB_CONCURRENCY; i++)
if (web_jobs[i].client == client)
web_close(&web_jobs[i]);
client->pending = 0;
client->scanning = false;
}
static void web_finish(struct web_job *job, bool open)
{
struct web_client *client = job->client;
if (!client) return;
if (open) client->checking |= 1U << job->port;
client->pending--;
web_close(job);
if (client->next_port == WEB_PORT_COUNT && !client->pending) {
client->open_ports = client->checking;
client->ready = true;
client->scanning = false;
client->checked = web_now();
client->next_scan = client->checked + WEB_INTERVAL_MS;
}
uloop_timeout_set(&web_pump, 250);
}
static void web_timeout(struct uloop_timeout *timeout)
{
struct web_job *job = container_of(timeout, struct web_job, timeout);
web_finish(job, false);
}
static void web_connected(struct uloop_fd *fd, unsigned int events)
{
struct web_job *job = container_of(fd, struct web_job, fd);
int error = 0;
socklen_t len = sizeof(error);
bool open = !getsockopt(fd->fd, SOL_SOCKET, SO_ERROR, &error, &len) && !error;
web_finish(job, open);
}
static void web_start(struct web_job *job, struct web_client *client)
{
job->client = client;
job->port = client->next_port++;
client->pending++;
job->fd.fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
job->fd.cb = web_connected;
job->timeout.cb = web_timeout;
if (job->fd.fd < 0) { web_finish(job, false); return; }
struct sockaddr_in remote = { .sin_family = AF_INET,
.sin_addr = client->address, .sin_port = htons(web_ports[job->port]) };
int ret = connect(job->fd.fd, (void *)&remote, sizeof(remote));
if (!ret) { web_finish(job, true); return; }
if (errno != EINPROGRESS || uloop_fd_add(&job->fd, ULOOP_WRITE) < 0) {
web_finish(job, false);
return;
}
uloop_timeout_set(&job->timeout, WEB_TIMEOUT_MS);
}
static void web_run(struct uloop_timeout *timeout)
{
uint64_t now = web_now();
struct web_client *client;
for (int i = 0; i < WEB_CONCURRENCY; i++) {
if (web_jobs[i].client) continue;
struct web_client *selected = NULL;
avl_for_each_element(&web_clients, client, avl) {
if ((client->scanning && client->next_port >= WEB_PORT_COUNT) ||
(!client->scanning && now < client->next_scan))
continue;
if (!selected || client->next_scan < selected->next_scan)
selected = client;
}
if (selected) {
if (!selected->scanning) {
selected->scanning = true;
selected->next_port = selected->pending = selected->checking = 0;
}
web_start(&web_jobs[i], selected);
}
}
/* A completed connect wakes the queue. When idle, sleep until the next
* due client instead of polling the cache every second. */
bool free_slot = false;
for (int i = 0; i < WEB_CONCURRENCY; i++)
if (!web_jobs[i].client) free_slot = true;
if (free_slot) {
uint64_t delay = WEB_INTERVAL_MS;
avl_for_each_element(&web_clients, client, avl) {
if (client->scanning && client->next_port >= WEB_PORT_COUNT) continue;
uint64_t due = client->scanning || client->next_scan <= now ? 250 : client->next_scan - now;
if (due < delay) delay = due;
}
uloop_timeout_set(&web_pump, delay < 250 ? 250 : delay);
}
}
static void web_add_client(const struct in_addr *address, const unsigned char *mac)
{
uint32_t ip = ntohl(address->s_addr);
struct web_client *client;
/* Targets come only from local lease files, never from RPC input. */
if (!ip || (ip >> 24) == 127 || (ip >> 24) == 0 || ip >= 0xe0000000U)
return;
client = avl_find_element(&web_clients, address, client, avl);
if (!client) {
if (web_clients.count >= WEB_MAX_CLIENTS || !(client = calloc(1, sizeof(*client))))
return;
client->address = *address;
client->avl.key = &client->address;
avl_insert(&web_clients, &client->avl);
}
if (memcmp(client->mac, mac, 6)) {
web_cancel_client(client);
memcpy(client->mac, mac, 6);
client->open_ports = client->checking = 0;
client->ready = false;
client->checked = client->next_scan = 0;
}
client->generation = web_generation;
}
static void web_refresh(struct uloop_timeout *timeout)
{
struct web_client *client, *next;
web_generation++;
rpc_luci_dhcp4_clients(web_add_client);
avl_for_each_element_safe(&web_clients, client, avl, next) {
if (client->generation != web_generation) {
web_cancel_client(client);
avl_delete(&web_clients, &client->avl);
free(client);
}
}
uloop_timeout_set(&web_pump, 250);
uloop_timeout_set(&web_inventory, WEB_LEASE_REFRESH_MS);
}
static int web_get(struct ubus_context *ctx, struct ubus_object *object,
struct ubus_request_data *req, const char *method, struct blob_attr *msg)
{
struct web_client *client;
uint64_t now = web_now();
blob_buf_init(&web_result, 0);
blobmsg_add_u32(&web_result, "interval_ms", WEB_INTERVAL_MS);
void *clients = blobmsg_open_table(&web_result, "clients");
avl_for_each_element(&web_clients, client, avl) {
char ip[INET_ADDRSTRLEN], mac[18];
inet_ntop(AF_INET, &client->address, ip, sizeof(ip));
snprintf(mac, sizeof(mac), "%02X:%02X:%02X:%02X:%02X:%02X",
client->mac[0], client->mac[1], client->mac[2], client->mac[3], client->mac[4], client->mac[5]);
void *entry = blobmsg_open_table(&web_result, ip);
blobmsg_add_string(&web_result, "mac", mac);
blobmsg_add_u8(&web_result, "ready", client->ready);
blobmsg_add_u32(&web_result, "age_ms", client->ready ? now - client->checked : 0);
void *ports = blobmsg_open_array(&web_result, "ports");
if (client->ready && now - client->checked < 2 * WEB_INTERVAL_MS)
for (int i = 0; i < WEB_PORT_COUNT; i++)
if (client->open_ports & (1U << i))
blobmsg_add_u32(&web_result, NULL, web_ports[i]);
blobmsg_close_array(&web_result, ports);
blobmsg_close_table(&web_result, entry);
}
blobmsg_close_table(&web_result, clients);
ubus_send_reply(ctx, req, web_result.head);
return 0;
}
int rpc_luci_webprobe_init(struct ubus_context *ctx)
{
static const struct ubus_method methods[] = { UBUS_METHOD_NOARG("get", web_get) };
static struct ubus_object_type type = UBUS_OBJECT_TYPE("luci-client-web", methods);
static struct ubus_object object = { .name = "luci.client-web", .type = &type,
.methods = methods, .n_methods = 1 };
int ret = ubus_add_object(ctx, &object);
if (!ret && !web_initialized) {
avl_init(&web_clients, web_compare, false, NULL);
for (int i = 0; i < WEB_CONCURRENCY; i++) web_jobs[i].fd.fd = -1;
web_inventory.cb = web_refresh;
web_pump.cb = web_run;
web_initialized = true;
uloop_timeout_set(&web_inventory, 5000);
}
return ret;
}
+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.