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:
+23
-7
@@ -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)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user