feat(mini): per-protocol connectivity probe in hbc_mini.py
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uc9V1v7iXruubpoLiSE24M
This commit is contained in:
+312
-1
@@ -30,15 +30,17 @@ import re
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import zlib
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import defaultdict
|
||||
from dataclasses import asdict, dataclass
|
||||
from logging.handlers import SysLogHandler
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional, Set, Tuple
|
||||
|
||||
# updated by scripts/bumpminor.sh
|
||||
__version__ = "5.4.4"
|
||||
@@ -637,6 +639,315 @@ class DiskMonitorPlugin(MonitorPlugin):
|
||||
return {"partitions": partitions} if partitions else {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Connectivity probe — per-protocol internet presence (IPv4 / IPv6)
|
||||
#
|
||||
# Tier 1 (every tier1_interval 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. A family is absent after no route at all or fail_limit
|
||||
# consecutive failed echoes, present after one success, and unknown (flag omitted) when it
|
||||
# cannot be probed. The result rides on every HTB as p4/p6 and, when network_monitor runs, as
|
||||
# its `connectivity` metric. Mirrors hbd/client/connectivity.py.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_FAMILIES = ("ipv4", "ipv6")
|
||||
_FLAG_KEYS = {"ipv4": "p4", "ipv6": "p6"}
|
||||
_DEFAULT_PROBES = {"ipv4": "1.1.1.1", "ipv6": "2606:4700:4700::1111"}
|
||||
_BSD_PLATFORMS = ("darwin", "freebsd", "netbsd", "openbsd", "dragonfly")
|
||||
|
||||
|
||||
class _ProbeUnavailable(Exception):
|
||||
"""The address family cannot be probed on this host (e.g. no IPv6 support)."""
|
||||
|
||||
|
||||
class _PingUnavailable(Exception):
|
||||
"""The system ping binary could not be executed."""
|
||||
|
||||
|
||||
def _parse_proc_route_v4(text: str) -> Optional[str]:
|
||||
"""Default gateway from /proc/net/route (lowest metric wins)."""
|
||||
best: Optional[Tuple[int, str]] = None
|
||||
for line in text.splitlines()[1:]:
|
||||
f = line.split()
|
||||
if len(f) < 8 or f[1] != "00000000" or not int(f[3], 16) & 0x2:
|
||||
continue
|
||||
metric = int(f[6])
|
||||
if best is None or metric < best[0]:
|
||||
# the kernel prints the raw network-order u32 in host order: use the host byte order
|
||||
best = (metric, socket.inet_ntoa(int(f[2], 16).to_bytes(4, sys.byteorder)))
|
||||
return best[1] if best else None
|
||||
|
||||
|
||||
def _parse_proc_route_v6(text: str) -> Optional[str]:
|
||||
"""Default gateway from /proc/net/ipv6_route (lowest metric wins)."""
|
||||
best: Optional[Tuple[int, str]] = None
|
||||
for line in text.splitlines():
|
||||
f = line.split()
|
||||
if len(f) < 10:
|
||||
continue
|
||||
dest, plen, nexthop, metric, flags, iface = f[0], f[1], f[4], f[5], f[8], f[9]
|
||||
if dest != "0" * 32 or plen != "00" or nexthop == "0" * 32 or iface == "lo":
|
||||
continue
|
||||
fl = int(flags, 16)
|
||||
if not fl & 0x2 or fl & 0x200: # need RTF_GATEWAY, skip RTF_REJECT
|
||||
continue
|
||||
m = int(metric, 16)
|
||||
if best is None or m < best[0]:
|
||||
best = (m, socket.inet_ntop(socket.AF_INET6, bytes.fromhex(nexthop)))
|
||||
return best[1] if best else None
|
||||
|
||||
|
||||
def _parse_route_get(text: str) -> Optional[str]:
|
||||
"""Gateway from `route -n get [-inet6] default` (macOS and the BSDs)."""
|
||||
m = re.search(r"^\s*gateway:\s*(\S+)", text, re.MULTILINE)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _parse_windows_route(text: str, family: str) -> Optional[str]:
|
||||
"""Default gateway from `route print -4` / `route print -6` (lowest metric wins)."""
|
||||
best: Optional[Tuple[int, str]] = None
|
||||
for line in text.splitlines():
|
||||
if family == "ipv4":
|
||||
f = line.split()
|
||||
if not (len(f) >= 5 and f[0] == "0.0.0.0" and f[1] == "0.0.0.0" and f[2] != "On-link"):
|
||||
continue
|
||||
cand = (int(f[4]), f[2])
|
||||
else:
|
||||
m = re.match(r"^\s*\d+\s+(\d+)\s+::/0\s+(\S+)\s*$", line)
|
||||
if not m or m.group(2) == "On-link":
|
||||
continue
|
||||
cand = (int(m.group(1)), m.group(2))
|
||||
if best is None or cand[0] < best[0]:
|
||||
best = cand
|
||||
return best[1] if best else None
|
||||
|
||||
|
||||
async def _run_cmd(cmd: List[str], timeout: float = 5.0) -> str:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL)
|
||||
try:
|
||||
out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
raise
|
||||
return out.decode(errors="replace")
|
||||
|
||||
|
||||
async def _default_gateway(family: str) -> Optional[str]:
|
||||
"""Default gateway for "ipv4"/"ipv6", or None on any failure (informational only)."""
|
||||
v4 = family == "ipv4"
|
||||
try:
|
||||
if sys.platform.startswith("linux"):
|
||||
with open("/proc/net/route" if v4 else "/proc/net/ipv6_route") as fh:
|
||||
text = fh.read()
|
||||
return _parse_proc_route_v4(text) if v4 else _parse_proc_route_v6(text)
|
||||
if sys.platform.startswith(_BSD_PLATFORMS):
|
||||
cmd = ["route", "-n", "get"] + ([] if v4 else ["-inet6"]) + ["default"]
|
||||
return _parse_route_get(await _run_cmd(cmd))
|
||||
if sys.platform == "win32":
|
||||
text = await _run_cmd(["route", "print", "-4" if v4 else "-6"])
|
||||
return _parse_windows_route(text, family)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _probe_ping_command(host: str, family: str) -> List[str]:
|
||||
"""Command for ONE echo request with a ~2 s reply wait."""
|
||||
v4 = family == "ipv4"
|
||||
if sys.platform == "win32":
|
||||
return ["ping", "-n", "1", "-w", "2000", "-4" if v4 else "-6", host]
|
||||
if sys.platform.startswith(_BSD_PLATFORMS):
|
||||
# BSD/macOS -W is in milliseconds, and IPv6 is reached through ping6.
|
||||
return ["ping", "-c", "1", "-W", "2000", host] if v4 else ["ping6", "-c", "1", host]
|
||||
return ["ping", "-c", "1", "-W", "2", "-4" if v4 else "-6", host]
|
||||
|
||||
|
||||
def _parse_ping_rtt(output: str) -> Optional[float]:
|
||||
"""Average RTT in ms from ping output, or None when there was no reply."""
|
||||
m = re.search(r"(?:rtt|round-trip)\s+min/avg/max/\S+\s*=\s*([\d.]+)/([\d.]+)/([\d.]+)", output)
|
||||
if m:
|
||||
return float(m.group(2))
|
||||
if "TTL=" in output: # Windows reply line (also when localized)
|
||||
m = re.search(r"[=<]\s*(\d+)\s*ms", output)
|
||||
return float(m.group(1)) if m else 0.0
|
||||
return None
|
||||
|
||||
|
||||
async def _ping_rtt(target: str, family: str) -> Optional[float]:
|
||||
"""One ICMP echo; RTT in ms, or None if no reply. Raises _PingUnavailable without ping."""
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*_probe_ping_command(target, family),
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
||||
except FileNotFoundError as e:
|
||||
raise _PingUnavailable(str(e))
|
||||
try:
|
||||
out, _ = await asyncio.wait_for(proc.communicate(), timeout=6)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
return None
|
||||
return _parse_ping_rtt(out.decode(errors="replace"))
|
||||
|
||||
|
||||
def _local_address(family: str, target: str) -> Optional[str]:
|
||||
"""Source address the kernel would use to reach target, or None if there is no route.
|
||||
|
||||
A UDP connect() sends nothing. Raises _ProbeUnavailable if the family is unsupported.
|
||||
"""
|
||||
af = socket.AF_INET if family == "ipv4" else socket.AF_INET6
|
||||
try:
|
||||
sock = socket.socket(af, socket.SOCK_DGRAM)
|
||||
except OSError as e:
|
||||
raise _ProbeUnavailable(str(e))
|
||||
try:
|
||||
sock.connect((target, 9))
|
||||
return str(sock.getsockname()[0])
|
||||
except OSError:
|
||||
return None
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FamilyState:
|
||||
present: Optional[bool] = None # None = unknown (probe cannot run yet/at all)
|
||||
addr: Optional[str] = None
|
||||
gateway: Optional[str] = None
|
||||
rtt: Optional[float] = None
|
||||
|
||||
|
||||
class _Connectivity:
|
||||
def __init__(
|
||||
self,
|
||||
probes: Optional[Dict[str, str]] = None,
|
||||
probe_interval: float = 60.0,
|
||||
retry_interval: float = 10.0,
|
||||
tier1_interval: float = 5.0,
|
||||
fail_limit: int = 2,
|
||||
local_address_fn: Callable[[str, str], Optional[str]] = _local_address,
|
||||
ping_fn: Callable[[str, str], Awaitable[Optional[float]]] = _ping_rtt,
|
||||
gateway_fn: Callable[[str], Awaitable[Optional[str]]] = _default_gateway,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
):
|
||||
self.probes = dict(_DEFAULT_PROBES)
|
||||
self.probes.update(probes or {})
|
||||
self.probe_interval = probe_interval
|
||||
self.retry_interval = retry_interval
|
||||
self.tier1_interval = tier1_interval
|
||||
self.fail_limit = fail_limit
|
||||
self._local_address = local_address_fn
|
||||
self._ping = ping_fn
|
||||
self._gateway_fn = gateway_fn
|
||||
self._clock = clock
|
||||
# Reading the gateway is a cheap /proc read on Linux but a subprocess elsewhere, so
|
||||
# elsewhere it is refreshed only when a tier-2 probe is due.
|
||||
self._cheap_gateway = sys.platform.startswith("linux")
|
||||
self.states: Dict[str, _FamilyState] = {f: _FamilyState() for f in _FAMILIES}
|
||||
self._fails: Dict[str, int] = {f: 0 for f in _FAMILIES}
|
||||
self._next_ping: Dict[str, float] = {f: 0.0 for f in _FAMILIES}
|
||||
self._last_key: Dict[str, Optional[Tuple[Optional[str], Optional[str]]]] = {
|
||||
f: None for f in _FAMILIES}
|
||||
self._ping_disabled: Set[str] = set()
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, cfg: Optional[Dict[str, Any]]) -> "_Connectivity":
|
||||
cfg = cfg or {}
|
||||
probes = {}
|
||||
if cfg.get("probe_v4"):
|
||||
probes["ipv4"] = str(cfg["probe_v4"])
|
||||
if cfg.get("probe_v6"):
|
||||
probes["ipv6"] = str(cfg["probe_v6"])
|
||||
return cls(probes=probes, probe_interval=float(cfg.get("probe_interval", 60)))
|
||||
|
||||
# ---- readers (cheap; safe to call on every heartbeat) ---------------------------
|
||||
def snapshot(self) -> Dict[str, _FamilyState]:
|
||||
return {f: _FamilyState(**asdict(s)) for f, s in self.states.items()}
|
||||
|
||||
def flags(self) -> Dict[str, bool]:
|
||||
"""HTB flags for families whose presence is known."""
|
||||
return {_FLAG_KEYS[f]: s.present for f, s in self.states.items() if s.present is not None}
|
||||
|
||||
def as_metric(self) -> Dict[str, Dict[str, Any]]:
|
||||
return {f: asdict(s) for f, s in self.states.items()}
|
||||
|
||||
# ---- probing ----------------------------------------------------------------------
|
||||
async def refresh(self) -> None:
|
||||
for fam in _FAMILIES:
|
||||
try:
|
||||
await self._refresh_family(fam)
|
||||
except Exception:
|
||||
logging.getLogger("hbc.probe").exception("connectivity probe failed for %s", fam)
|
||||
|
||||
async def run(self) -> None:
|
||||
while _running:
|
||||
await self.refresh()
|
||||
await _sleep(self.tier1_interval)
|
||||
|
||||
async def _gateway(self, fam: str) -> Optional[str]:
|
||||
try:
|
||||
return await self._gateway_fn(fam)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def _refresh_family(self, fam: str) -> None:
|
||||
st = self.states[fam]
|
||||
try:
|
||||
addr = self._local_address(fam, self.probes[fam])
|
||||
except _ProbeUnavailable:
|
||||
st.present = None
|
||||
return
|
||||
|
||||
if addr is None: # tier 1: no route => absent immediately
|
||||
self._fails[fam] = 0
|
||||
self._next_ping[fam] = 0.0
|
||||
self._last_key[fam] = None
|
||||
st.present, st.addr, st.gateway, st.rtt = False, None, None, None
|
||||
return
|
||||
|
||||
now = self._clock()
|
||||
gw = await self._gateway(fam) if self._cheap_gateway else st.gateway
|
||||
key = (addr, gw)
|
||||
due = key != self._last_key[fam] or now >= self._next_ping[fam]
|
||||
if due and not self._cheap_gateway:
|
||||
gw = await self._gateway(fam)
|
||||
key = (addr, gw)
|
||||
st.addr, st.gateway = addr, gw
|
||||
self._last_key[fam] = key
|
||||
if fam in self._ping_disabled:
|
||||
st.present = None # cannot probe: unknown, never absent, while a route exists
|
||||
elif due:
|
||||
await self._tier2(fam, st, now)
|
||||
|
||||
async def _tier2(self, fam: str, st: _FamilyState, now: float) -> None:
|
||||
try:
|
||||
rtt = await self._ping(self.probes[fam], fam)
|
||||
except _PingUnavailable:
|
||||
logging.getLogger("hbc.probe").warning("ping unavailable; %s presence stays unknown", fam)
|
||||
self._ping_disabled.add(fam)
|
||||
st.present = None
|
||||
return
|
||||
except (asyncio.TimeoutError, OSError):
|
||||
rtt = None
|
||||
if rtt is None:
|
||||
self._fails[fam] += 1
|
||||
self._next_ping[fam] = now + self.retry_interval
|
||||
if self._fails[fam] >= self.fail_limit:
|
||||
st.present, st.rtt = False, None
|
||||
else:
|
||||
self._fails[fam] = 0
|
||||
self._next_ping[fam] = now + self.probe_interval
|
||||
st.present, st.rtt = True, rtt
|
||||
|
||||
|
||||
# The running probe (None when probe_enabled is false); shared by the heartbeat sender and
|
||||
# the network_monitor plugin. Set in _async_main.
|
||||
_probe: Optional[_Connectivity] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin: network_monitor (Linux /proc/net/dev)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user