feat(mini-c): connectivity probe core with unit tests
Adds the pure/core probe logic for the standalone C client (hbc_mini.c): gateway text parsers for /proc/net/route, /proc/net/ipv6_route and BSD `route -n get`, a ping-output RTT parser, and the probe_step state machine (probe_t/probe_fam_t/probe_ops_t) plus probe_flags and probe_metric_json. config_t gains probe_enabled/probe_interval/probe_v4/probe_v6. Temporary stub functions (sys_local_address, sys_ping, sys_gateway, mono_ts) stand in for real syscalls until Task 4 wires them into main(). New scripts/c/test_probe.c test harness (#include's hbc_mini.c with main renamed out of the way) plus a `make test` target; scripts/c/.gitignore now ignores the test_probe binary. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uc9V1v7iXruubpoLiSE24M
This commit is contained in:
@@ -396,6 +396,10 @@ typedef struct {
|
||||
|
||||
char net_skip[MAX_NET_SKIP][64];
|
||||
int net_skip_count;
|
||||
|
||||
bool probe_enabled;
|
||||
int probe_interval;
|
||||
char probe_v4[64], probe_v6[64];
|
||||
} config_t;
|
||||
|
||||
static void config_defaults(config_t *c) {
|
||||
@@ -410,6 +414,10 @@ static void config_defaults(config_t *c) {
|
||||
c->net_interval = 300;
|
||||
snprintf(c->net_skip[0], 64, "lo");
|
||||
c->net_skip_count = 1;
|
||||
c->probe_enabled = true;
|
||||
c->probe_interval = 60;
|
||||
snprintf(c->probe_v4, sizeof(c->probe_v4), "1.1.1.1");
|
||||
snprintf(c->probe_v6, sizeof(c->probe_v6), "2606:4700:4700::1111");
|
||||
}
|
||||
|
||||
static void config_load(config_t *cfg, const char *path) {
|
||||
@@ -498,6 +506,10 @@ static void config_load(config_t *cfg, const char *path) {
|
||||
jval_t *net = plugins ? jget(plugins,"network_monitor") : jget(root,"network_monitor");
|
||||
if (net && net->type == JT_OBJECT) {
|
||||
if ((v=jget(net,"interval"))) cfg->net_interval = jint(v, cfg->net_interval);
|
||||
if ((v=jget(net,"probe_enabled"))) cfg->probe_enabled = jint(v, 1) != 0;
|
||||
if ((v=jget(net,"probe_interval"))) cfg->probe_interval = jint(v, cfg->probe_interval);
|
||||
if ((v=jget(net,"probe_v4"))) snprintf(cfg->probe_v4, sizeof(cfg->probe_v4), "%s", jstr(v, cfg->probe_v4));
|
||||
if ((v=jget(net,"probe_v6"))) snprintf(cfg->probe_v6, sizeof(cfg->probe_v6), "%s", jstr(v, cfg->probe_v6));
|
||||
jval_t *sk = jget(net,"skip_interfaces");
|
||||
if (sk && sk->type == JT_ARRAY) {
|
||||
cfg->net_skip_count = 0;
|
||||
@@ -984,6 +996,195 @@ static void plugin_disk_monitor(conn_t *c, const config_t *cfg) {
|
||||
LOGD("sent disk_monitor");
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Connectivity probe: per-protocol internet presence (IPv4 / IPv6)
|
||||
*
|
||||
* Tier 1 (every PROBE_TIER1 s, sends no packets): a UDP connect() to the probe address shows
|
||||
* whether a route exists and which source address would be used. Tier 2 (every probe_interval
|
||||
* s, sooner after a failure or a change of address or gateway): one ICMP echo, only when tier 1
|
||||
* found a route. Absent = no route, or PROBE_FAIL_LIMIT consecutive failed echoes; present =
|
||||
* one success; unknown (flag omitted) when the family cannot be probed. The result rides on
|
||||
* every HTB as p4/p6 and inside the network_monitor metric. Mirrors hbd/client/connectivity.py.
|
||||
* ============================================================ */
|
||||
|
||||
#define PROBE_UNKNOWN (-1)
|
||||
#define PROBE_FAIL_LIMIT 2
|
||||
#define PROBE_TIER1 5 /* seconds between route checks */
|
||||
|
||||
typedef struct {
|
||||
int present; /* PROBE_UNKNOWN, 0 = absent, 1 = present */
|
||||
char addr[INET6_ADDRSTRLEN];
|
||||
char gateway[INET6_ADDRSTRLEN];
|
||||
double rtt; /* ms, < 0 when unknown */
|
||||
int fails; /* consecutive failed echoes */
|
||||
double next_ping; /* monotonic seconds */
|
||||
char last_key[2 * INET6_ADDRSTRLEN + 2];
|
||||
bool ping_disabled; /* no usable ping binary */
|
||||
} probe_fam_t;
|
||||
|
||||
typedef struct {
|
||||
/* 1 = route exists (source address in out), 0 = no route, -1 = family unsupported/unusable */
|
||||
int (*local_address)(int af, const char *target, char *out, size_t n);
|
||||
/* RTT in ms; -1 = no reply; -2 = ping unavailable */
|
||||
double (*ping)(int af, const char *target);
|
||||
/* fills out with the default gateway, "" when unknown */
|
||||
void (*gateway)(int af, char *out, size_t n);
|
||||
} probe_ops_t;
|
||||
|
||||
typedef struct {
|
||||
probe_fam_t fam[2]; /* [0] = IPv4, [1] = IPv6 */
|
||||
char target[2][64];
|
||||
double probe_interval, retry_interval;
|
||||
bool cheap_gateway; /* gateway read is cheap (/proc), so read every tier 1 */
|
||||
pthread_mutex_t mu; /* guards fam[] between the probe thread and readers */
|
||||
} probe_t;
|
||||
|
||||
/* Lowest-metric default gateway from /proc/net/route. The kernel prints the raw network-order
|
||||
* u32 in host order, so it can be stored straight into s_addr on any endianness. */
|
||||
static void gw_parse_proc_v4(const char *text, char *out, size_t n) {
|
||||
long best = -1; out[0] = '\0';
|
||||
const char *p = strchr(text, '\n'); /* skip the header line */
|
||||
while (p && *++p) {
|
||||
char iface[64], dest[16], gw[16]; unsigned flags; int refcnt, use, metric;
|
||||
if (sscanf(p, "%63s %15s %15s %x %d %d %d", iface, dest, gw, &flags, &refcnt, &use, &metric) == 7
|
||||
&& !strcmp(dest, "00000000") && (flags & 0x2)) {
|
||||
if (best < 0 || metric < best) {
|
||||
struct in_addr a; a.s_addr = (uint32_t)strtoul(gw, NULL, 16);
|
||||
if (inet_ntop(AF_INET, &a, out, n)) best = metric;
|
||||
}
|
||||
}
|
||||
p = strchr(p, '\n');
|
||||
}
|
||||
}
|
||||
|
||||
/* Lowest-metric default gateway from /proc/net/ipv6_route (skips RTF_REJECT and "lo"). */
|
||||
static void gw_parse_proc_v6(const char *text, char *out, size_t n) {
|
||||
long best = -1; out[0] = '\0';
|
||||
const char *zero = "00000000000000000000000000000000";
|
||||
for (const char *p = text; p && *p; ) {
|
||||
char dest[40], src[40], nh[40], iface[64];
|
||||
unsigned plen, splen, metric, refcnt, use, flags;
|
||||
if (sscanf(p, "%32s %x %32s %x %32s %x %x %x %x %63s",
|
||||
dest, &plen, src, &splen, nh, &metric, &refcnt, &use, &flags, iface) == 10
|
||||
&& !strcmp(dest, zero) && plen == 0 && strcmp(nh, zero) && strcmp(iface, "lo")
|
||||
&& (flags & 0x2) && !(flags & 0x200)) {
|
||||
if (best < 0 || (long)metric < best) {
|
||||
struct in6_addr a; memset(&a, 0, sizeof(a));
|
||||
bool ok = strlen(nh) == 32;
|
||||
for (int i = 0; ok && i < 16; i++) {
|
||||
unsigned b;
|
||||
if (sscanf(nh + 2 * i, "%2x", &b) != 1) ok = false; else a.s6_addr[i] = (uint8_t)b;
|
||||
}
|
||||
if (ok && inet_ntop(AF_INET6, &a, out, n)) best = (long)metric;
|
||||
}
|
||||
}
|
||||
p = strchr(p, '\n'); if (p) p++;
|
||||
}
|
||||
}
|
||||
|
||||
/* Gateway from `route -n get [-inet6] default` (the BSDs). */
|
||||
static void gw_parse_route_get(const char *text, char *out, size_t n) {
|
||||
out[0] = '\0';
|
||||
const char *p = strstr(text, "gateway:");
|
||||
if (!p) return;
|
||||
p += 8;
|
||||
while (*p == ' ' || *p == '\t') p++;
|
||||
size_t i = 0;
|
||||
while (*p && !isspace((unsigned char)*p) && i + 1 < n) out[i++] = *p++;
|
||||
out[i] = '\0';
|
||||
}
|
||||
|
||||
/* Average RTT from ping output: -1.0 no reply, -2.0 ping could not be run. Works for Linux
|
||||
* ("rtt min/avg/max/mdev") and BSD ("round-trip min/avg/max/stddev"). */
|
||||
static double ping_parse_rtt(const char *out) {
|
||||
if (strstr(out, "not found") || strstr(out, "No such file")) return -2.0;
|
||||
const char *p = strstr(out, "min/avg/max");
|
||||
if (!p) return -1.0;
|
||||
p = strchr(p, '=');
|
||||
double mn, avg, mx;
|
||||
if (p && sscanf(p + 1, " %lf/%lf/%lf", &mn, &avg, &mx) == 3) return avg;
|
||||
return -1.0;
|
||||
}
|
||||
|
||||
/* One probe step for one family (f: 0 = IPv4, 1 = IPv6). Works on *s only, so the probe thread
|
||||
* can run it on a private copy without holding the mutex during the (blocking) echo. */
|
||||
static void probe_step(const probe_t *p, probe_fam_t *s, int f, const probe_ops_t *ops, double now) {
|
||||
int af = f ? AF_INET6 : AF_INET;
|
||||
char addr[INET6_ADDRSTRLEN] = "", gw[INET6_ADDRSTRLEN] = "";
|
||||
|
||||
int r = ops->local_address(af, p->target[f], addr, sizeof(addr));
|
||||
if (r < 0) { s->present = PROBE_UNKNOWN; return; }
|
||||
if (r == 0) { /* tier 1: no route => absent immediately */
|
||||
s->fails = 0; s->next_ping = 0; s->last_key[0] = '\0';
|
||||
s->present = 0; s->addr[0] = '\0'; s->gateway[0] = '\0'; s->rtt = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (p->cheap_gateway) ops->gateway(af, gw, sizeof(gw));
|
||||
else snprintf(gw, sizeof(gw), "%s", s->gateway);
|
||||
char key[sizeof(s->last_key)];
|
||||
snprintf(key, sizeof(key), "%s|%s", addr, gw);
|
||||
bool due = strcmp(key, s->last_key) != 0 || now >= s->next_ping;
|
||||
if (due && !p->cheap_gateway) {
|
||||
ops->gateway(af, gw, sizeof(gw));
|
||||
snprintf(key, sizeof(key), "%s|%s", addr, gw);
|
||||
}
|
||||
snprintf(s->addr, sizeof(s->addr), "%s", addr);
|
||||
snprintf(s->gateway, sizeof(s->gateway), "%s", gw);
|
||||
snprintf(s->last_key, sizeof(s->last_key), "%s", key);
|
||||
|
||||
if (s->ping_disabled) { s->present = PROBE_UNKNOWN; return; } /* cannot probe: unknown, never absent */
|
||||
if (!due) return;
|
||||
double rtt = ops->ping(af, p->target[f]);
|
||||
if (rtt < -1.5) { s->ping_disabled = true; s->present = PROBE_UNKNOWN; return; }
|
||||
if (rtt < 0) {
|
||||
s->fails++;
|
||||
s->next_ping = now + p->retry_interval;
|
||||
if (s->fails >= PROBE_FAIL_LIMIT) { s->present = 0; s->rtt = -1; }
|
||||
} else {
|
||||
s->fails = 0;
|
||||
s->next_ping = now + p->probe_interval;
|
||||
s->present = 1; s->rtt = rtt;
|
||||
}
|
||||
}
|
||||
|
||||
/* HTB flags for the families whose presence is known. */
|
||||
static void probe_flags(probe_t *p, kvdict_t *d) {
|
||||
pthread_mutex_lock(&p->mu);
|
||||
if (p->fam[0].present >= 0) kv_set_int(d, "p4", p->fam[0].present);
|
||||
if (p->fam[1].present >= 0) kv_set_int(d, "p6", p->fam[1].present);
|
||||
pthread_mutex_unlock(&p->mu);
|
||||
}
|
||||
|
||||
/* The `connectivity` metric as a JSON object; returns its length or -1 on overflow. */
|
||||
static int probe_metric_json(probe_t *p, char *out, size_t n) {
|
||||
static const char *names[2] = { "ipv4", "ipv6" };
|
||||
size_t len = 0;
|
||||
len += (size_t)snprintf(out + len, n - len, "{");
|
||||
pthread_mutex_lock(&p->mu);
|
||||
for (int f = 0; f < 2 && len < n; f++) {
|
||||
const probe_fam_t *s = &p->fam[f];
|
||||
char pres[8], addr[64], gw[64], rtt[32];
|
||||
snprintf(pres, sizeof(pres), "%s", s->present < 0 ? "null" : s->present ? "true" : "false");
|
||||
if (s->addr[0]) snprintf(addr, sizeof(addr), "\"%s\"", s->addr); else snprintf(addr, sizeof(addr), "null");
|
||||
if (s->gateway[0]) snprintf(gw, sizeof(gw), "\"%s\"", s->gateway); else snprintf(gw, sizeof(gw), "null");
|
||||
if (s->rtt >= 0) snprintf(rtt, sizeof(rtt), "%.3f", s->rtt); else snprintf(rtt, sizeof(rtt), "null");
|
||||
len += (size_t)snprintf(out + len, n - len,
|
||||
"%s\"%s\":{\"present\":%s,\"addr\":%s,\"gateway\":%s,\"rtt\":%s}",
|
||||
f ? "," : "", names[f], pres, addr, gw, rtt);
|
||||
}
|
||||
pthread_mutex_unlock(&p->mu);
|
||||
if (len + 2 > n) { out[0] = '\0'; return -1; }
|
||||
out[len++] = '}'; out[len] = '\0';
|
||||
return (int)len;
|
||||
}
|
||||
|
||||
/* --- replaced by the real implementations in the next task --- */
|
||||
static int sys_local_address(int af, const char *t, char *o, size_t n) { (void)af; (void)t; (void)o; (void)n; return -1; }
|
||||
static double sys_ping(int af, const char *t) { (void)af; (void)t; return -2.0; }
|
||||
static void sys_gateway(int af, char *o, size_t n) { (void)af; (void)n; o[0] = '\0'; }
|
||||
static double mono_ts(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); return ts.tv_sec + ts.tv_nsec * 1e-9; }
|
||||
|
||||
/* ============================================================
|
||||
* Plugin: network_monitor
|
||||
* Linux: /proc/net/dev
|
||||
|
||||
Reference in New Issue
Block a user