From 2f1cad91560d9bf25aaf611ed92141448d6f6d71 Mon Sep 17 00:00:00 2001 From: Andreas Wrede Date: Mon, 21 Sep 2026 09:25:33 -0400 Subject: [PATCH] feat(server): host presence, absent state and host-level status Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Uc9V1v7iXruubpoLiSE24M --- hbd/server/hbdclass.py | 46 ++++++++++++++++++- tests/test_host_status.py | 94 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 tests/test_host_status.py diff --git a/hbd/server/hbdclass.py b/hbd/server/hbdclass.py index 433ff1d..715008b 100644 --- a/hbd/server/hbdclass.py +++ b/hbd/server/hbdclass.py @@ -29,6 +29,7 @@ class Connection: UP = "up" DOWN = "down" OVERDUE = "overdue" + ABSENT = "absent" def __init__(self, host, cid, addr, afam): self.host = host @@ -94,7 +95,9 @@ class Connection: now = time.time() if not Null: d["addr"] = self.addr - if self.rtts[-1]: + if self.state == Connection.ABSENT: + d["rtt"] = "" + elif self.rtts[-1]: d["rtt"] = "%d" % round(self.rtts[-1]) elif self.state == Connection.UNKNOWN: d["rtt"] = "" @@ -299,6 +302,8 @@ class Host: self.alert_states = {} # Stale-data timers: {plugin_name: asyncio.TimerHandle} self.plugin_timers = {} + # Client-reported protocol presence: {afam: {"present": bool, "ts": float}} + self.presence: dict = {} # User access control self.owner: str | None = None # username of owner self.managers: list = [] # usernames with manager role @@ -322,6 +327,7 @@ class Host: d["name"] = "%s" % d["name"] d["dyn"] = str(self.dyn) d["num"] = self.num + d["status"] = self.status() # Add alert counts (split by acknowledged status) warning_unacked = 0 @@ -444,6 +450,7 @@ class Host: _, os_data = latest_os hbc_version = os_data.get("hbc_version") ddict["hbc_version"] = hbc_version + ddict["status"] = self.status() return ddict @@ -459,6 +466,41 @@ class Host: else: return addr.find(".") > 0 + def set_presence(self, afam, present, now): + """Record the client's report of whether it has internet connectivity on afam.""" + self.presence[afam] = {"present": bool(present), "ts": now} + + def flag_present(self, afam): + """Client-reported presence; True when the client never reported (legacy clients).""" + entry = getattr(self, "presence", {}).get(afam) + return True if entry is None else entry["present"] + + def family_present(self, afam): + """True if afam should be monitored: the client says it is present, or heartbeats + are demonstrably arriving on it (real traffic overrides a false "absent").""" + conn = self.connections.get(afam) + return self.flag_present(afam) or (conn is not None and conn.state == Connection.UP) + + def status(self): + """Host-level status: down, overdue, simplex, up or unknown. + + overdue: every present family is overdue. simplex: both present, one up, one overdue + (same severity as up). Families the client reports absent are not considered. + """ + conns = self.connections + if any(c.state == Connection.DOWN for c in conns.values()): + return "down" + present = [c for afam, c in conns.items() if self.family_present(afam)] + up = sum(c.state == Connection.UP for c in present) + overdue = sum(c.state == Connection.OVERDUE for c in present) + if up and overdue: + return "simplex" + if up: + return "up" + if overdue: + return "overdue" + return "unknown" + def conndata(self, cid, addr, rtt, now): if addr[0:7] == "::ffff:": addr = addr[7:] @@ -499,6 +541,8 @@ class Host: self.monitors = [] if not hasattr(self, "plugin_timers"): self.plugin_timers = {} + if not hasattr(self, "presence"): + self.presence = {} pass diff --git a/tests/test_host_status.py b/tests/test_host_status.py new file mode 100644 index 0000000..35d11cf --- /dev/null +++ b/tests/test_host_status.py @@ -0,0 +1,94 @@ +import time + +import pytest + +from hbd.server import hbdclass +from hbd.server.hbdclass import Connection as C + + +def _host(name): + hbdclass.Host.hosts.pop(name, None) + return hbdclass.Host(name) + + +def _conn(host, afam, state): + addr = "192.0.2.1" if afam == "IPv4" else "2001:db8::1" + conn, _ = host.conndata(0, addr, None, 1000.0) + conn.state = state + return conn + + +CASES = [ + # v4 state, v6 state, presence flags, expected status + (C.UP, C.UP, {}, "up"), + (C.UP, C.OVERDUE, {}, "simplex"), # both present (legacy) => simplex + (C.UP, C.OVERDUE, {"IPv6": True}, "simplex"), + (C.UP, C.OVERDUE, {"IPv6": False}, "up"), # v6 not present => not monitored + (C.OVERDUE, C.OVERDUE, {}, "overdue"), + (C.OVERDUE, C.ABSENT, {"IPv6": False}, "overdue"), # only present family is overdue + (C.UP, C.ABSENT, {"IPv6": False}, "up"), + (C.DOWN, C.UP, {}, "down"), + (C.UNKNOWN, C.UNKNOWN, {}, "unknown"), + (C.OVERDUE, C.UNKNOWN, {}, "overdue"), + (C.UP, C.UNKNOWN, {}, "up"), + (C.OVERDUE, C.UP, {"IPv6": False}, "simplex"), # HTB arriving => present despite flag +] + + +@pytest.mark.parametrize("s4,s6,flags,expected", CASES) +def test_status_matrix(s4, s6, flags, expected): + h = _host("status-matrix") + _conn(h, "IPv4", s4) + _conn(h, "IPv6", s6) + for afam, present in flags.items(): + h.set_presence(afam, present, time.time()) + assert h.status() == expected + + +def test_status_single_family_host(): + h = _host("status-v4only") + _conn(h, "IPv4", C.UP) + assert h.status() == "up" + h.connections["IPv4"].state = C.OVERDUE + assert h.status() == "overdue" + + +def test_status_no_connections_is_unknown(): + assert _host("status-empty").status() == "unknown" + + +def test_family_present_rules(): + h = _host("fp") + v6 = _conn(h, "IPv6", C.OVERDUE) + assert h.family_present("IPv6") is True # never reported => present + h.set_presence("IPv6", False, 1.0) + assert h.family_present("IPv6") is False + v6.state = C.UP # real traffic overrides the flag + assert h.family_present("IPv6") is True + assert h.flag_present("IPv6") is False + + +def test_stateinfo_and_statedict_expose_status(): + h = _host("status-exposed") + _conn(h, "IPv4", C.UP) + assert h.stateinfo()["status"] == "up" + assert h.statedict()["status"] == "up" + + +def test_absent_connection_statedict_is_neutral(): + h = _host("absent-dict") + conn = _conn(h, "IPv6", C.ABSENT) + conn.rtts.append(42.0) + d = conn.statedict() + assert d["state"] == "absent" and d["rtt"] == "" and d["rttstate"] == "absent" + + +def test_presence_survives_pickle_and_old_pickles_get_default(): + import pickle + h = _host("pickled") + h.set_presence("IPv6", False, 5.0) + h2 = pickle.loads(pickle.dumps(h)) + assert h2.presence == {"IPv6": {"present": False, "ts": 5.0}} + del h2.__dict__["presence"] # simulate a pre-feature pickle + h2.fixup() + assert h2.presence == {}