diff --git a/scripts/hbc_mini.py b/scripts/hbc_mini.py index a8408a3..20880d0 100755 --- a/scripts/hbc_mini.py +++ b/scripts/hbc_mini.py @@ -10,14 +10,20 @@ Built-in plugins (always available): os_info — OS name, kernel, distro, Python version ping_monitor — ICMP ping RTT to configured hosts nagios_runner — run Nagios-compatible check scripts + network_monitor — per-interface TX/RX bytes/s (Linux); per-protocol connectivity (all platforms) Linux-only plugins (via /proc, no extra tools needed): cpu_monitor — CPU %, load average, core count memory_monitor — RAM and swap usage - network_monitor — per-interface TX/RX bytes/s Cross-platform (via df subprocess): disk_monitor — per-mount usage and capacity + +Presence probe (built in): reports per protocol (IPv4/IPv6) whether the host has internet +connectivity, as p4/p6 flags on every heartbeat, so hbd stops treating a missing IPv6 (or +IPv4) path as an outage. Config, in the network_monitor section of ~/.hbc.json: + probe_enabled (default true), probe_v4 (default 1.1.1.1), probe_v6 (default + 2606:4700:4700::1111; IP literals only), probe_interval (default 60 s) """ import argparse @@ -969,7 +975,7 @@ def _read_net_dev() -> Dict[str, Tuple[int, int]]: class NetworkMonitorPlugin(MonitorPlugin): name = "network_monitor" - description = "Network I/O rates via /proc/net/dev (Linux)" + description = "Network I/O rates via /proc/net/dev (Linux) and per-protocol internet connectivity" interval = 300 def __init__(self, config: Optional[Dict[str, Any]] = None): @@ -978,19 +984,25 @@ class NetworkMonitorPlugin(MonitorPlugin): self.interval = cfg.get("interval", 300) self.skip_ifaces: set = set(cfg.get("skip_interfaces", ["lo"])) self._prev: Optional[Tuple[float, Dict[str, Tuple[int, int]]]] = None + self._have_dev = False async def initialize(self) -> bool: - if platform.system() != "Linux": - self.skip_reason = "Linux only (/proc/net/dev not available)" + dev = _read_net_dev() if platform.system() == "Linux" else {} + self._have_dev = bool(dev) + if not self._have_dev and _probe is None: + self.skip_reason = "no /proc/net/dev (Linux only) and the connectivity probe is disabled" return False - dev = _read_net_dev() - if not dev: - self.skip_reason = "/proc/net/dev not readable" - return False - self._prev = (time.time(), dev) # prime first delta + if self._have_dev: + self._prev = (time.time(), dev) # prime first delta return True async def _collect_metrics(self) -> Dict[str, Any]: + data: Dict[str, Any] = await self._collect_interfaces() if self._have_dev else {} + if _probe is not None: + data["connectivity"] = _probe.as_metric() + return data + + async def _collect_interfaces(self) -> Dict[str, Any]: now = time.time() curr = _read_net_dev() if not curr or self._prev is None: @@ -1251,15 +1263,21 @@ async def _handle_update(conn: AsyncConnection): # Heartbeat sender # --------------------------------------------------------------------------- -async def _heartbeat_sender(conn: AsyncConnection, interval: int): +def _build_heartbeat_msg(conn: AsyncConnection, interval: int, + probe: Optional[_Connectivity] = None) -> Dict[str, Any]: + """HTB payload; carries p4/p6 presence flags when the probe knows them.""" + msg: Dict[str, Any] = {"acks": conn.ackcount, "rtt": conn.rtts[-1], "interval": interval} + if probe is not None: + msg.update(probe.flags()) + return msg + + +async def _heartbeat_sender(conn: AsyncConnection, interval: int, + probe: Optional[_Connectivity] = None): log = logging.getLogger("hbc.hb") while _running: try: - await conn.sendto({ - "acks": conn.ackcount, - "rtt": conn.rtts[-1], - "interval": interval, - }) + await conn.sendto(_build_heartbeat_msg(conn, interval, probe)) except Exception as e: log.error("send: %s", e) await _sleep(interval) @@ -1367,7 +1385,7 @@ def _reconfigure_syslog(level: int): # --------------------------------------------------------------------------- async def _async_main(args, cfg: Dict[str, Any]) -> int: - global _running, _shutdown_event, _active_tasks, send_shutdown + global _running, _shutdown_event, _active_tasks, send_shutdown, _probe _running = True _shutdown_event = asyncio.Event() _active_tasks = [] @@ -1424,6 +1442,10 @@ async def _async_main(args, cfg: Dict[str, Any]) -> int: c.close() return 0 + # Per-protocol internet presence probe (shared with the network_monitor plugin) + net_cfg = dict((cfg.get("plugins") or {}).get("network_monitor") or cfg.get("network_monitor") or {}) + _probe = _Connectivity.from_config(net_cfg) if net_cfg.get("probe_enabled", True) else None + plugins = await _load_plugins(cfg) loop = asyncio.get_event_loop() @@ -1437,8 +1459,10 @@ async def _async_main(args, cfg: Dict[str, Any]) -> int: loop.add_signal_handler(signal.SIGHUP, _sighup) + if _probe is not None: + _active_tasks.append(asyncio.create_task(_probe.run())) for conn in connections: - _active_tasks.append(asyncio.create_task(_heartbeat_sender(conn, interval))) + _active_tasks.append(asyncio.create_task(_heartbeat_sender(conn, interval, _probe))) if plugins and connections: _active_tasks.append(asyncio.create_task(_plugin_collector(connections[0], plugins))) diff --git a/tests/test_hbc_mini_probe.py b/tests/test_hbc_mini_probe.py index ca9090d..56ed61d 100644 --- a/tests/test_hbc_mini_probe.py +++ b/tests/test_hbc_mini_probe.py @@ -304,3 +304,78 @@ def test_local_address_on_this_host_never_raises(): except mini._ProbeUnavailable: continue assert r is None or isinstance(r, str) + + +# ---- heartbeat message and plugin wiring --------------------------------------------- + +class _Conn: + ackcount = 3 + rtts = [0.0, 12.5] + + +class _FlagsProbe: + def __init__(self, flags): + self._flags = flags + + def flags(self): + return self._flags + + +def test_heartbeat_without_probe_is_unchanged(): + assert mini._build_heartbeat_msg(_Conn(), 60) == {"acks": 3, "rtt": 12.5, "interval": 60} + + +def test_heartbeat_carries_presence_flags(): + msg = mini._build_heartbeat_msg(_Conn(), 60, _FlagsProbe({"p4": True, "p6": False})) + assert msg == {"acks": 3, "rtt": 12.5, "interval": 60, "p4": True, "p6": False} + + +def test_heartbeat_omits_unknown_flags(): + msg = mini._build_heartbeat_msg(_Conn(), 60, _FlagsProbe({})) + assert "p4" not in msg and "p6" not in msg + + +def test_flags_survive_the_wire_encoding(): + msg = mini._build_heartbeat_msg(_Conn(), 60, _FlagsProbe({"p4": True, "p6": False})) + out = mini._stodict(mini._dicttos("HTB", dict(msg, name="h", id=1))) + assert out["p4"] == 1 and out["p6"] == 0 + + +class _FakeProbe: + def as_metric(self): + return {"ipv4": {"present": True, "addr": "192.0.2.5", "gateway": "192.0.2.1", "rtt": 9.0}, + "ipv6": {"present": False, "addr": None, "gateway": None, "rtt": None}} + + +def test_plugin_reports_connectivity_alongside_interfaces(monkeypatch): + monkeypatch.setattr(mini, "_probe", _FakeProbe()) + monkeypatch.setattr(mini.platform, "system", lambda: "Linux") + plugin = mini.NetworkMonitorPlugin({}) + assert asyncio.run(plugin.initialize()) is True + data = asyncio.run(plugin._collect_metrics()) + assert data["connectivity"]["ipv4"]["present"] is True + assert data["connectivity"]["ipv6"]["present"] is False + + +def test_plugin_probe_only_mode_on_non_linux(monkeypatch): + monkeypatch.setattr(mini, "_probe", _FakeProbe()) + monkeypatch.setattr(mini.platform, "system", lambda: "FreeBSD") + plugin = mini.NetworkMonitorPlugin({}) + assert asyncio.run(plugin.initialize()) is True + assert list(asyncio.run(plugin._collect_metrics())) == ["connectivity"] + + +def test_plugin_still_skips_on_non_linux_without_probe(monkeypatch): + monkeypatch.setattr(mini, "_probe", None) + monkeypatch.setattr(mini.platform, "system", lambda: "FreeBSD") + plugin = mini.NetworkMonitorPlugin({}) + assert asyncio.run(plugin.initialize()) is False + assert plugin.skip_reason + + +def test_plugin_omits_connectivity_without_probe(monkeypatch): + monkeypatch.setattr(mini, "_probe", None) + monkeypatch.setattr(mini.platform, "system", lambda: "Linux") + plugin = mini.NetworkMonitorPlugin({}) + assert asyncio.run(plugin.initialize()) is True + assert "connectivity" not in asyncio.run(plugin._collect_metrics())