diff --git a/.gitignore b/.gitignore index fcaa10b..6dad32f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,4 @@ __pycache__/ .venv/ test/ build/ -dist/ *.egg-info/ \ No newline at end of file diff --git a/.hbc.swp b/.hbc.swp new file mode 100644 index 0000000..37211fa Binary files /dev/null and b/.hbc.swp differ diff --git a/dist/hbd-5.0-py3-none-any.whl b/dist/hbd-5.0-py3-none-any.whl new file mode 100644 index 0000000..4018a46 Binary files /dev/null and b/dist/hbd-5.0-py3-none-any.whl differ diff --git a/dist/hbd-5.0.tar.gz b/dist/hbd-5.0.tar.gz new file mode 100644 index 0000000..b2343a3 Binary files /dev/null and b/dist/hbd-5.0.tar.gz differ diff --git a/hbc b/hbd/hbc.py similarity index 78% rename from hbc rename to hbd/hbc.py index b4bd33f..e507a29 100755 --- a/hbc +++ b/hbd/hbc.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 # $Id: hbc,v 1.9 2012/03/29 02:08:36 andreas Exp $ # NEW +import argparse import sys import time import socket @@ -18,6 +19,7 @@ import subprocess import syslog import codecs +from .config import load_config PORT = 50003 INTERVAL = 10 @@ -30,6 +32,17 @@ running = True dorestart = False warned1 = False +msgonly = False +helpflag = False +verbose = False +fdaemon = False +daemonized = False +optlist = [] +msgboot = {} +home = os.environ["HOME"] +configfile = "%s/.hbrc" % home +cmdargs = [] +iam = socket.gethostname() def log(msg): if fdaemon: @@ -460,181 +473,121 @@ def daemonize( os.dup2(so.fileno(), sys.stdout.fileno()) os.dup2(se.fileno(), sys.stderr.fileno()) - -msgonly = False -helpflag = False -verbose = False -fdaemon = False -daemonized = False -optlist = [] -args = [] -msgboot = {} -home = os.environ["HOME"] -configfile = "%s/.hbrc" % home -cmdargs = [] -iam = socket.gethostname() - - -try: - optlist, args = getopt.getopt(sys.argv[1:], "bc:dhm:n:v") -except: - helpflag = True - -for o, a in optlist: - if o == "-b": - msgboot["boot"] = 1 - elif o == "-c": - configfile = a - cmdargs += [o, a] - elif o == "-d": - fdaemon = True - cmdargs += [o] - elif o == "-h": - helpflag = True - elif o == "-m": - msgboot["service"] = "service" - msgboot["msg"] = a - msgonly = True - elif o == "-n": - iam = a - cmdargs += [o, a] - elif o == "-v": - verbose = True - cmdargs += [o] - - -cmdargs += args -if verbose: - print("cmdargs for restart are %s" % cmdargs) - -if helpflag: - print("hbc HeartBeatClient") - print("usage: hbc [-bdhv] [-c configfile] [-m msg][host1 [..]]") - print() - print(" -b indicate machine boot") - print(" -c configfile") - print(" -d daemonize") - print(" -h this help") - print(" -m send a message") - print(" -v verbose") - print() - print( - """ config file can contain -hb_hosts=('host1', 'host2', ..._ -hb_port=50003 -interval=20 -logfile=... -logfmt={|test|msg} -grace=SECONDS -reportstrict={True|False} -""" +# +# Main program +# +def build_parser(): + parser = argparse.ArgumentParser( + prog="hbc", + description="HeartBeatClient - send a heatbeat message to a HeartBeatDaemon", + formatter_class=argparse.RawDescriptionHelpFormatter, ) + parser.add_argument("-b", "--boot", action="store_true", help="Send a boot message") + parser.add_argument("-c", "--config", dest="configfile", help="Config file path (YAML)") + parser.add_argument("-m", "--message", dest="message", help="Send a message") + parser.add_argument("-n", "--name", dest="name", help="Name to use in heartbeat message") + parser.add_argument("-f", "--daemon", action="store_true", help="Run in daemon mode") + parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output") + parser.add_argument("-x", "--debug", action="count", default=0, help="Increase debug level") + parser.add_argument("hosts", nargs="+", help="Heartbeat daemon hosts to send to") + return parser - sys.exit(1) +def main(argv=None): + global msgonly, helpflag, verbose, fdaemon, daemonized, optlist, msgboot, home, configfile, cmdargs, iam, hb_port, conns, interval, hb_hosts + parser = build_parser() + args = parser.parse_args(argv) + + config = load_config(args.configfile) -# -# set defaults + # Apply CLI overrides + if args.boot: + msgboot["boot"] = 1 + if args.message: + msgboot["service"] = "service" + msgboot["msg"] = args.message + msgonly = True + if args.name: + iam = args.name + if args.daemon: + fdaemon = True + if args.verbose: + verbose = True + if args.debug: + config.setdefault("debug", 0) + config["debug"] += args.debug -hb_port = PORT -interval = INTERVAL -hb_hosts = [] - -try: - f = open(configfile, "r") + cmdargs += argv if verbose: - print("notice: using config file %s" % configfile) -except: - if verbose: - print("warning: running without config file: %s" % configfile) - f = None + print("cmdargs for restart are %s" % cmdargs) + + # + # set defaults + + hb_hosts = args.hosts + hb_port = config.get("hb_port", PORT) + interval = config.get("interval", INTERVAL) -if f: - while 1: - l = f.readline() - if len(l) == 0: + # + if verbose: + print("notice: hb_hosts: %s" % str(hb_hosts)) + print("notice: hb_port: %s" % hb_port) + print("notice: interval: %s" % interval) + print("notice: iam: %s" % iam) + print("notice: msgonly: %s" % msgonly) + print("notice: msgboot: %s" % msgboot) + + if not msgonly: + msgboot["interval"] = interval + + conns = {} + while True: + if verbose: + log("create connections") + createConnections(hb_hosts) + if len(conns) != 0: break - r = l[:-1].split("=") - if r[0] == "hb_hosts": - hb_hosts = eval(r[1]) - if verbose: - print("notice: cfg hb_hosts: %s" % hb_hosts) - elif r[0] == "interval": - interval = eval(r[1]) - elif r[0] == "hb_port": - hb_port = eval(r[1]) - elif r[0] == "name": - iam = eval(r[1]) - if verbose: - print("name set to %s" % iam) - f.close() - -if len(args) != 0: - hb_hosts = args - - -if len(hb_hosts) == 0: - print("no hb server specified") - sys.exit(1) - -# -if verbose: - print("notice: hb_hosts: %s" % str(hb_hosts)) - print("notice: hb_port: %s" % hb_port) - print("notice: interval: %s" % interval) - print("notice: iam: %s" % iam) - print("notice: msgonly: %s" % msgonly) - print("notice: msgboot: %s" % msgboot) - -if not msgonly: - msgboot["interval"] = interval - - -conns = {} -while True: + if verbose: + log("no connections yet, sleep a bit") + time.sleep(2) + if verbose: - log("create connections") - createConnections(hb_hosts) - if len(conns) != 0: - break + log("%s connections created" % (len(conns))) + + if len(msgboot) > 0: + if verbose: + print("on boot") + msgboot["acks"] = 0 + for conn in conns: + conns[conn].sendto(msgboot) + + if msgonly: + if verbose: + print("msgboot done msgonly=%s" % msgonly) + closeall() + sys.exit(0) + + # + syslog.openlog("hbc", syslog.LOG_PID, syslog.LOG_DAEMON) + if fdaemon: + print("daemoinizing.") + daemonize() + daemonized = True + syslog.syslog(syslog.LOG_ERR, "starting heartbeat to %s" % ",".join(hb_hosts)) + + signal.signal(signal.SIGTERM, handler) + running = True + try: + process() + except Exception as e: + syslogtrace("process") + if verbose: + print("err: process exit: %s" % e) + if verbose: - log("no connections yet, sleep a bit") - time.sleep(2) - -if verbose: - log("%s connections created" % (len(conns))) - -if len(msgboot) > 0: - if verbose: - print("on boot") - msgboot["acks"] = 0 - for conn in conns: - conns[conn].sendto(msgboot) - -if msgonly: - if verbose: - print("msgboot done msgonly=%s" % msgonly) - closeall() - sys.exit(0) - -# -syslog.openlog("hbc", syslog.LOG_PID, syslog.LOG_DAEMON) -if fdaemon: - print("daemoinizing.") - daemonize() - daemonized = True - syslog.syslog(syslog.LOG_ERR, "starting heartbeat to %s" % ",".join(hb_hosts)) - -signal.signal(signal.SIGTERM, handler) -running = True -try: - process() -except Exception as e: - syslogtrace("process") - if verbose: - print("err: process exit: %s" % e) - -if verbose: - log("main: cleanup") -cleanup() -if dorestart: - restart() + log("main: cleanup") + cleanup() + if dorestart: + restart() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/hbd/http.py b/hbd/http.py index f18d918..582650f 100644 --- a/hbd/http.py +++ b/hbd/http.py @@ -131,10 +131,14 @@ async def start( return web.Response(text="restart request") async def live(request): - # render template from templates/live.html using Jinja2 - env = jinja2.Environment(loader=jinja2.FileSystemLoader(config.get("templates_dir", "templates"))) + # render template from hbd/templates/live.html using Jinja2 + # Resolve templates directory relative to the hbd package + pkg_dir = os.path.dirname(__file__) + templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates")) + env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir)) host = config.get("hb_host", "localhost") extra_scripts = config.get("http_extra_scripts", "") + host = request.host.split(":")[0] heartbeat_ws_url = f"ws://{host}:{config.get('ws_port', 50005)}/hbd" tmpl = env.get_template("live.html") body = tmpl.render( diff --git a/hbd/install.sh b/hbd/install.sh new file mode 100644 index 0000000..c86adf1 --- /dev/null +++ b/hbd/install.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +# install hbd/hbc from wheel and create symlinks for hbd and hbc in /usr/local/bin + +set -e +pip install --upgrade --force-reinstall --no-deps --find-links https://github.com/andreas-h/heartbeat/releases/latest/download/ heartbeat-hbd \ No newline at end of file diff --git a/hbd/server.py b/hbd/server.py index 435b435..3b28a93 100644 --- a/hbd/server.py +++ b/hbd/server.py @@ -9,6 +9,7 @@ from . import __version__ from . import udp from . import hbdclass + from . import ws as ws_mod logger = logging.getLogger(__name__) @@ -73,7 +74,7 @@ async def _run_async(config): # prepare runtime dependencies import threading - from . import hbdclass +# from . import hbdclass from . import http as http_mod from . import dns as dns_mod from . import notify as notify_mod @@ -254,7 +255,7 @@ def load_pickled_hosts(config, hbdclass): lastfm = ["", "", ""] pickf.close() except Exception as e: - print(("load pickled failed: %s" % e)) + logger.exception("load pickled failed: %s", e) os.unlink(pickfile) hbdclass.Connection.htab = {} for h in list(hbdclass.Host.hosts.keys()): diff --git a/templates/foot.html b/hbd/templates/foot.html similarity index 100% rename from templates/foot.html rename to hbd/templates/foot.html diff --git a/templates/head.html b/hbd/templates/head.html similarity index 100% rename from templates/head.html rename to hbd/templates/head.html diff --git a/templates/live.html b/hbd/templates/live.html similarity index 83% rename from templates/live.html rename to hbd/templates/live.html index fd5507c..3e277a8 100644 --- a/templates/live.html +++ b/hbd/templates/live.html @@ -51,6 +51,40 @@ th:not(.sorttable_sorted):not(.sorttable_sorted_reverse):not(.sorttable_nosort):after { content: " \2195"; } + + /* Modal for connection status messages */ + .connection-modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.4); + } + + .connection-modal.show { + display: flex; + justify-content: center; + align-items: center; + } + + .connection-modal-content { + background-color: #f9f9f9; + padding: 20px; + border: 1px solid #888; + border-radius: 5px; + text-align: center; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); + min-width: 300px; + } + + .connection-modal-content p { + margin: 10px 0; + font-size: 16px; + color: #333; + } diff --git a/templates/menu.html b/hbd/templates/menu.html similarity index 100% rename from templates/menu.html rename to hbd/templates/menu.html diff --git a/hbd/udp.py b/hbd/udp.py index 509c422..35db4af 100644 --- a/hbd/udp.py +++ b/hbd/udp.py @@ -1,6 +1,6 @@ """UDP listener and datagram processing.""" import asyncio -from compression import zlib +import zlib import logging logger = logging.getLogger(__name__) diff --git a/hbdclass.py b/hbdclass.py new file mode 100644 index 0000000..ed7e16d --- /dev/null +++ b/hbdclass.py @@ -0,0 +1,380 @@ +""" +host and connection class shared between hbd and +the websit's heartbeat.py + +""" + +import time +import json +import copy +import queue + +num = 0 + +MAXRTTS = 10 + +DEBUG = 2 + + +def log(host, m): + if DEBUG: + print("class log: %s %s" % (host, m)) + + +class Connection: + # map of addrs to names + + htab = {} + UNKNOWN = "unknown" + UP = "up" + DOWN = "down" + OVERDUE = "overdue" + + def __init__(self, host, cid, addr, afam): + self.host = host + self.cid = cid + if addr[0:7] == "::ffff:": + addr = addr[7:] + self.addr = addr + self.afam = afam + self.rtts = [0] + self.lastbeat = time.time() + self.statetime = self.lastbeat + self.deltastatetime = "computed" + self.state = Connection.UNKNOWN + + if host: + Connection.htab[addr] = self.host.name + if self.host.isDynDns(): + log(self.host.name, "dns update %s" % self.addr) + Host.dnsQ.put((self.host.name, self.addr)) + + def registerDns(self): + Host.dnsQ.put((self.host.name, self.addr)) + + def clearstate(self): + d = {} + d["addr"] = "" + d["rtt"] = "" + d["lastbeat"] = "" + d["state"] = "" + d["statetime"] = "" + d["deltastatetime"] = "" + d["rttstate"] = "" + return d + + def statedict(self, Null=False): + d = self.clearstate() + now = time.time() + if not Null: + d["addr"] = self.addr + if self.rtts[-1]: + d["rtt"] = "%0.1f" % self.rtts[-1] + elif self.state == Connection.UNKNOWN: + d["rtt"] = "" + else: + d["rtt"] = "?" + d["lastbeat"] = self.lastbeat + if self.state == Connection.OVERDUE: + d["state"] = "%s" % self.state + else: + d["state"] = self.state + if self.state == Connection.UP: + d["rttstate"] = d["rtt"] + elif self.state == Connection.OVERDUE: + d["rttstate"] = "" + else: + d["rttstate"] = d["state"] + d["statetime"] = time.strftime( + "%Y-%m-%d %H:%M:%S", time.localtime(self.statetime) + ) + delta = now - self.statetime + + if self.state == Connection.UNKNOWN: + d["deltastatetime"] = "" + elif delta > 86400: + # d['deltastatetime'] = time.strftime("%d %H:%M:%S", time.gmtime(delta)) + d["deltastatetime"] = "%0.1f days" % (delta / 86400.0) + elif delta > 3600: + # d['deltastatetime'] = time.strftime("%H:%M:%S", time.gmtime(delta)) + d["deltastatetime"] = time.strftime("%k:%M hrs", time.gmtime(delta)) + # d['deltastatetime'] = "%0.1f hrs" % (delta / 3600.) + elif delta > 60: + # d['deltastatetime'] = time.strftime("%M:%S", time.gmtime(delta)) + d["deltastatetime"] = time.strftime("%M:%S mins", time.gmtime(delta)) + # d['deltastatetime'] = "%0.1f mins" % (delta / 60.) + else: + # d['deltastatetime'] = time.strftime("%S", time.gmtime(delta)) + d["deltastatetime"] = "%i secs" % (delta) + if self.state == Connection.UNKNOWN and now - self.lastbeat > 86400 * 10: + d = self.clearstate() + + return d + + def headerdict(self, afam): + d = {} + d["addr"] = "%s Addr" % afam + d["rtt"] = "Latencey" + d["lastbeat"] = "Last Contact" + d["state"] = "State" + d["statetime"] = "Last State" + d["rttstate"] = "Reach" + d["deltastatetime"] = "Last State" + return d + + def jsons(self): + return json.dumps(self.__dict__) + + # set new state, return number of secs in previous state + def newstate(self, state, now, when=0): + self.state = state + delta = now - when + s = delta - self.statetime + self.statetime = delta + return s + + def getstate(self): + return self.state + + def newaddr(self, addr, rtt, now): + self.lastbeat = now + self.rtts.append(rtt) + if len(self.rtts) > MAXRTTS: + del self.rtts[0] + + if self.addr == addr: + r = None + else: + r = "changed from %s to %s" % (self.addr, addr) + try: + del Connection.htab[self.addr] + except: + pass + self.addr = addr + Connection.htab[addr] = self.host.name + if self.host.isDynDns(): + Host.dnsQ.put((self.host.name, self.addr)) + return r + + +# +class Host: + # Table of Hosts + hosts = {} + dnsQ = queue.Queue() + + def __init__(self, name): + global num + self.name = name + if name: + num += 1 + Host.hosts[name] = self + self.num = num + self.dyn = False + self.watched = False + self.upcount = 0 + self.interval = 0 + self.doesack = -1 + self.cmds = [] + self.cver = 0 + self.connections = {} + self.hdwcounts = [[0, 0], [0, 0], [0, 0]] + + def statedict(self): + d = {} + d["name"] = self.name + if self.dyn: + d["name"] += "*" + if self.watched: + d["name"] = "%s" % d["name"] + d["dyn"] = str(self.dyn) + d["ver"] = str(self.cver) + d["num"] = self.num + for c in ["IPv4", "IPv6"]: + if c in self.connections: + cs = self.connections[c].statedict() + else: + cs = ubConnection.statedict(True) + for csv in cs: + d["%s.%s" % (c, csv)] = cs[csv] + + return d + + def headerdict(self): + d = {} + d["name"] = "Name" + d["dyn"] = "Dyn" + d["ver"] = "Ver" + d["num"] = "??" + for c in ["IPv4", "IPv6"]: + cs = ubConnection.headerdict(c) + for csv in cs: + d["%s.%s" % (c, csv)] = cs[csv] + return d + + def registerDns(self): + for af in self.connections: + self.connections[af].registerDns() + + def stateinfo(self): + ddict = {} + for d in self.__dict__: + if d == "connections": + cl = [] + for c in self.connections: + # dirty ugly hack: fix conn to host backpointer + cld = copy.deepcopy(self.connections[c].__dict__) + cld["host"] = cld["host"].name + cl.append(cld) + ddict[d] = cl + else: + ddict[d] = self.__dict__[d] + return ddict + + def jsons(self): + return json.dumps(self.stateinfo()) + + def setcver(self, cver): + self.cver = cver + + def isDynDns(self): + return self.dyn + + def isIPv4(self, addr): + if isinstance(addr, tuple): + return addr[0].find(".") > 0 + else: + return addr.find(".") > 0 + + def conndata(self, cid, addr, rtt, now): + if addr[0:7] == "::ffff:": + addr = addr[7:] + if self.isIPv4(addr): + afam = "IPv4" + else: + afam = "IPv6" + + if afam not in self.connections: + self.connections[afam] = Connection(self, cid, addr, afam) + + conn = self.connections[afam] + res = conn.newaddr(addr, rtt, now) + return conn, res + + # called when reloading class from pickle, add new fields here + def fixup(self): + for c in ["IPv4", "IPv6"]: + if c in self.connections: + addr = self.connections[c].addr + if addr[0:7] == "::ffff:": + addr = addr[7:] + self.connections[c].addr = addr + + pass + + # def dispstate(self): + # if self.state in ["down", "overdue"]: + # state = "%s" % self.state + # elif self.state in ["up", "UP"]: + # state = "" + # for x in list(self.connections.keys()): + # try: + # state += " %5.1f" % (self.connections[x].rtts[-1]) + # except: + # state += " %5s" % (self.connections[x].rtts[-1]) + # elif self.state in ["unknown", "UNKNOWN"]: + # state = "" + # else: + # state = "%s" % self.state + # return state + + def dispstats(self): + if self.doesack != -1: + if self.upcount > 0: + # return "(%0.1f%%) %s %s %s " % ((self.doesack * 100.0) / self.upcount, self.doesack, self.upcount, self.hdwcounts) + r = "" + for v in range(3): + a, u = self.hdwcounts[v] + if (self.upcount - u) != 0: + vs = "%0.0f" % ( + 100.0 - (((self.doesack - a) * 100.0) / (self.upcount - u)) + ) + if vs == "0": + vs = "" + else: + vs = "-" + r += '%s' % vs + return r + else: + return "(%s)" % (self.doesack) + return 'N/A>' + + hostfields_long = [ + "name", + "IPv4.addr", + "IPv4.state", + ("IPv4.rtt", 'style="text-align: right;"'), + ("IPv4.statetime", 'style="text-align: right;"'), + "IPv6.addr", + "IPv6.state", + ("IPv6.rtt", 'style="text-align: right;"'), + ("IPv6.statetime", 'style="text-align: right;"'), + "ver", + ] + + hostfields_short = [ + "name", + ("IPv4.rttstate", 'style="text-align: right;"'), + ("IPv4.deltastatetime", 'style="text-align: right;"'), + ("IPv6.rttstate", 'style="text-align: right;"'), + ("IPv6.deltastatetime", 'style="text-align: right;"'), + ] + + def gene(self, tag, v, attrib=None): + if attrib: + a = " %s" % attrib + else: + a = "" + return "<%s%s>%s" % (tag, a, v, tag) + + def htmltable(self, tag, hd, short): + if short: + hostfields = Host.hostfields_short + else: + hostfields = Host.hostfields_long + h = [] + for f in hostfields: + if isinstance(f, tuple): + h.append(self.gene(tag, hd[f[0]], f[1])) + else: + h.append(self.gene(tag, hd[f])) + return self.gene("tr", "\n".join(h)) + + def buildhosttable(self, short=False): + if DEBUG > 1: + print("DBG buildhosttable: start") + res = [] + res.append('') + res.append(ubHost.htmltable("th", ubHost.headerdict(), short)) + hosts_sorted = list(Host.hosts.keys()) + if len(hosts_sorted): + hosts_sorted.sort() + for h in hosts_sorted: + res.append(ubHost.htmltable("td", Host.hosts[h].statedict(), short)) + res.append("
") + if DEBUG > 1: + print("DBG buildhosttable: %s" % res) + return res + + def buildmsgtable(self, msgs): + res = [] + le = max(40 - len(Host.hosts), 3) + res.append("

Log of Events

") + for m in msgs[len(msgs) - le:]: + res.append("%s
" % m) + return res + + +# create fake "unbound objects", remove in Python 3.0 +ubHost = Host(None) +ubConnection = Connection(None, "", "", "") diff --git a/pyproject.toml b/pyproject.toml index bc1ce16..e65cfc7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,6 @@ readme = "README.md" requires-python = ">=3.10" license = { text = "MIT" } keywords = ["heartbeat", "monitoring", "dns", "websocket"] - authors = [ { name = "heartbeat contributors" } ] @@ -38,7 +37,11 @@ dev = [ [project.scripts] hbd = "hbd.cli:main" +hbc = "hbd.hbc:main" [tool.setuptools.packages.find] where = ["."] include = ["hbd*"] + +[tool.setuptools.package-data] +"hbd" = ["*.yaml", "static/*", "static/*/*", "templates/*"] \ No newline at end of file