feat(client): report per-protocol presence in HTB and network_monitor

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uc9V1v7iXruubpoLiSE24M
This commit is contained in:
2026-09-21 09:23:33 -04:00
co-authored by Claude Sonnet 5
parent c61a571995
commit 1898bc52e5
4 changed files with 88 additions and 7 deletions
+23 -7
View File
@@ -25,6 +25,7 @@ from .. import __version__
# Import plugin system # Import plugin system
from .plugin import PluginRegistry, PluginLoader, InfoPlugin, MonitorPlugin from .plugin import PluginRegistry, PluginLoader, InfoPlugin, MonitorPlugin
from .connectivity import Connectivity, set_shared
# Constants # Constants
PORT = 50003 PORT = 50003
@@ -281,7 +282,16 @@ async def handle_update(conn: AsyncConnection, _msg: dict): # pyright: ignore[r
stop() stop()
async def heartbeat_sender(conn: AsyncConnection, interval: int): def build_heartbeat_msg(conn, interval: int, connectivity: Optional[Connectivity] = None) -> dict:
"""HTB payload; carries p4/p6 presence flags when the probe knows them."""
msg = {"acks": conn.ackcount, "rtt": conn.rtts[-1], "interval": interval}
if connectivity is not None:
msg.update(connectivity.flags())
return msg
async def heartbeat_sender(conn: AsyncConnection, interval: int,
connectivity: Optional[Connectivity] = None):
"""Send periodic heartbeats, retrying the connection if it is not open. """Send periodic heartbeats, retrying the connection if it is not open.
IPv6 connections that fail to open before their first successful send are IPv6 connections that fail to open before their first successful send are
@@ -291,6 +301,7 @@ async def heartbeat_sender(conn: AsyncConnection, interval: int):
Args: Args:
conn: Connection to send on conn: Connection to send on
interval: Heartbeat interval in seconds interval: Heartbeat interval in seconds
connectivity: Optional probe whose presence flags are attached to every heartbeat
""" """
logger = logging.getLogger("hbc.heartbeat") logger = logging.getLogger("hbc.heartbeat")
IPV6_EARLY_FAIL_LIMIT = 3 IPV6_EARLY_FAIL_LIMIT = 3
@@ -328,11 +339,7 @@ async def heartbeat_sender(conn: AsyncConnection, interval: int):
continue continue
try: try:
msg = { msg = build_heartbeat_msg(conn, interval, connectivity)
"acks": conn.ackcount,
"rtt": conn.rtts[-1],
"interval": interval
}
await conn.sendto(msg, "HTB") await conn.sendto(msg, "HTB")
except asyncio.CancelledError: except asyncio.CancelledError:
@@ -620,8 +627,17 @@ async def async_main(args, config):
# Start async tasks # Start async tasks
# Heartbeat senders (one per connection) # Heartbeat senders (one per connection)
# Per-protocol internet presence probe (shared with the network_monitor plugin)
net_cfg = dict((config.get("plugins") or {}).get("network_monitor")
or config.get("network_monitor") or {})
connectivity = None
if net_cfg.get("probe_enabled", True):
connectivity = Connectivity.from_config(net_cfg)
set_shared(connectivity)
active_tasks.append(asyncio.create_task(connectivity.run(shutdown_event)))
for conn in connections: for conn in connections:
task = asyncio.create_task(heartbeat_sender(conn, interval)) task = asyncio.create_task(heartbeat_sender(conn, interval, connectivity))
active_tasks.append(task) active_tasks.append(task)
# Plugin collector (uses all connections, but we'll use first one) # Plugin collector (uses all connections, but we'll use first one)
+11
View File
@@ -13,6 +13,7 @@ except ImportError:
psutil = None psutil = None
from hbd.client.plugin import MonitorPlugin from hbd.client.plugin import MonitorPlugin
from hbd.client.connectivity import get_shared
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -32,6 +33,10 @@ class NetworkMonitorPlugin(MonitorPlugin):
interfaces: List of interfaces to monitor (default: all) interfaces: List of interfaces to monitor (default: all)
include_connections: Include connection statistics (default: True) include_connections: Include connection statistics (default: True)
include_addresses: Include interface addresses (default: False) include_addresses: Include interface addresses (default: False)
probe_enabled: Run the per-protocol internet presence probe (default: True)
probe_v4: IPv4 address to ping (default: 1.1.1.1)
probe_v6: IPv6 address to ping (default: 2606:4700:4700::1111)
probe_interval: Seconds between ICMP probes while healthy (default: 60)
""" """
name = "network_monitor" name = "network_monitor"
@@ -103,6 +108,7 @@ class NetworkMonitorPlugin(MonitorPlugin):
- etc. - etc.
- addresses: Interface address information (if include_addresses) - addresses: Interface address information (if include_addresses)
- Dict keyed by interface name with address details - Dict keyed by interface name with address details
- connectivity: per-protocol presence {ipv4, ipv6} -> {present, addr, gateway, rtt}
""" """
if psutil is None: if psutil is None:
logger.error("psutil not available") logger.error("psutil not available")
@@ -229,6 +235,11 @@ class NetworkMonitorPlugin(MonitorPlugin):
except Exception as e: except Exception as e:
logger.warning(f"Could not collect interface stats: {e}") logger.warning(f"Could not collect interface stats: {e}")
# Per-protocol internet presence, address and gateway (from the shared probe)
shared = get_shared()
if shared is not None:
metrics['connectivity'] = shared.as_metric()
return metrics return metrics
async def cleanup(self): async def cleanup(self):
+28
View File
@@ -0,0 +1,28 @@
from hbd.client.main import build_heartbeat_msg
class _Conn:
ackcount = 3
rtts = [0, 12.5]
class _Conn2:
def __init__(self, flags):
self._flags = flags
def flags(self):
return self._flags
def test_heartbeat_without_connectivity_is_unchanged():
assert build_heartbeat_msg(_Conn(), 60) == {"acks": 3, "rtt": 12.5, "interval": 60}
def test_heartbeat_carries_presence_flags():
msg = build_heartbeat_msg(_Conn(), 60, _Conn2({"p4": True, "p6": False}))
assert msg == {"acks": 3, "rtt": 12.5, "interval": 60, "p4": True, "p6": False}
def test_heartbeat_omits_unknown_flags():
msg = build_heartbeat_msg(_Conn(), 60, _Conn2({}))
assert "p4" not in msg and "p6" not in msg
@@ -0,0 +1,26 @@
import asyncio
from hbd.client import connectivity
from hbd.client.plugins.network_monitor import NetworkMonitorPlugin
class _FakeShared:
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_collect_includes_connectivity_when_probe_running():
connectivity.set_shared(_FakeShared())
try:
data = asyncio.run(NetworkMonitorPlugin()._collect_metrics())
finally:
connectivity.set_shared(None)
assert data["connectivity"]["ipv4"]["present"] is True
assert data["connectivity"]["ipv6"]["present"] is False
def test_collect_omits_connectivity_without_probe():
connectivity.set_shared(None)
data = asyncio.run(NetworkMonitorPlugin()._collect_metrics())
assert "connectivity" not in data