feat(server): host presence, absent state and host-level status
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uc9V1v7iXruubpoLiSE24M
This commit is contained in:
+45
-1
@@ -29,6 +29,7 @@ class Connection:
|
|||||||
UP = "up"
|
UP = "up"
|
||||||
DOWN = "down"
|
DOWN = "down"
|
||||||
OVERDUE = "overdue"
|
OVERDUE = "overdue"
|
||||||
|
ABSENT = "absent"
|
||||||
|
|
||||||
def __init__(self, host, cid, addr, afam):
|
def __init__(self, host, cid, addr, afam):
|
||||||
self.host = host
|
self.host = host
|
||||||
@@ -94,7 +95,9 @@ class Connection:
|
|||||||
now = time.time()
|
now = time.time()
|
||||||
if not Null:
|
if not Null:
|
||||||
d["addr"] = self.addr
|
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])
|
d["rtt"] = "%d" % round(self.rtts[-1])
|
||||||
elif self.state == Connection.UNKNOWN:
|
elif self.state == Connection.UNKNOWN:
|
||||||
d["rtt"] = ""
|
d["rtt"] = ""
|
||||||
@@ -299,6 +302,8 @@ class Host:
|
|||||||
self.alert_states = {}
|
self.alert_states = {}
|
||||||
# Stale-data timers: {plugin_name: asyncio.TimerHandle}
|
# Stale-data timers: {plugin_name: asyncio.TimerHandle}
|
||||||
self.plugin_timers = {}
|
self.plugin_timers = {}
|
||||||
|
# Client-reported protocol presence: {afam: {"present": bool, "ts": float}}
|
||||||
|
self.presence: dict = {}
|
||||||
# User access control
|
# User access control
|
||||||
self.owner: str | None = None # username of owner
|
self.owner: str | None = None # username of owner
|
||||||
self.managers: list = [] # usernames with manager role
|
self.managers: list = [] # usernames with manager role
|
||||||
@@ -322,6 +327,7 @@ class Host:
|
|||||||
d["name"] = "<b>%s</b>" % d["name"]
|
d["name"] = "<b>%s</b>" % d["name"]
|
||||||
d["dyn"] = str(self.dyn)
|
d["dyn"] = str(self.dyn)
|
||||||
d["num"] = self.num
|
d["num"] = self.num
|
||||||
|
d["status"] = self.status()
|
||||||
|
|
||||||
# Add alert counts (split by acknowledged status)
|
# Add alert counts (split by acknowledged status)
|
||||||
warning_unacked = 0
|
warning_unacked = 0
|
||||||
@@ -444,6 +450,7 @@ class Host:
|
|||||||
_, os_data = latest_os
|
_, os_data = latest_os
|
||||||
hbc_version = os_data.get("hbc_version")
|
hbc_version = os_data.get("hbc_version")
|
||||||
ddict["hbc_version"] = hbc_version
|
ddict["hbc_version"] = hbc_version
|
||||||
|
ddict["status"] = self.status()
|
||||||
|
|
||||||
return ddict
|
return ddict
|
||||||
|
|
||||||
@@ -459,6 +466,41 @@ class Host:
|
|||||||
else:
|
else:
|
||||||
return addr.find(".") > 0
|
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):
|
def conndata(self, cid, addr, rtt, now):
|
||||||
if addr[0:7] == "::ffff:":
|
if addr[0:7] == "::ffff:":
|
||||||
addr = addr[7:]
|
addr = addr[7:]
|
||||||
@@ -499,6 +541,8 @@ class Host:
|
|||||||
self.monitors = []
|
self.monitors = []
|
||||||
if not hasattr(self, "plugin_timers"):
|
if not hasattr(self, "plugin_timers"):
|
||||||
self.plugin_timers = {}
|
self.plugin_timers = {}
|
||||||
|
if not hasattr(self, "presence"):
|
||||||
|
self.presence = {}
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -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 == {}
|
||||||
Reference in New Issue
Block a user