From 5e6dfc75ad46434a123737a91857ed198341695e Mon Sep 17 00:00:00 2001 From: Andreas Wrede Date: Sun, 8 Feb 2026 16:05:03 -0500 Subject: [PATCH] link and flake cleanup --- .gitignore | 1 + README.md | 50 +++++++++++------ dist/hbd-5.0-py3-none-any.whl | Bin 37372 -> 0 bytes dist/hbd-5.0.tar.gz | Bin 38530 -> 0 bytes hbd/cli.py | 17 ++++-- hbd/config.py | 5 +- hbd/dns.py | 79 +++++++++++++++++++++----- hbd/hbc.py | 93 +++++++++++++++++-------------- hbd/hbdclass.py | 17 +++--- hbd/http.py | 21 +++++-- hbd/monitor.py | 24 +++++--- hbd/notify.py | 57 +++++++++++++++---- hbd/proto.py | 1 + hbd/server.py | 81 +++++++++++++++++---------- hbd/udp.py | 11 ++-- hbd/utils.py | 1 - hbd/ws.py | 29 +++++++--- pyproject.toml | 10 ++++ install.sh => scripts/install.sh | 0 tests/test_dns.py | 35 ++++++++++-- tests/test_handle_datagram.py | 36 ++++++------ tests/test_proto.py | 1 - tests/test_udp.py | 8 +-- tox.ini | 2 +- 24 files changed, 393 insertions(+), 186 deletions(-) delete mode 100644 dist/hbd-5.0-py3-none-any.whl delete mode 100644 dist/hbd-5.0.tar.gz rename install.sh => scripts/install.sh (100%) diff --git a/.gitignore b/.gitignore index 1271719..68e0917 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,6 @@ __pycache__/ .venv/ test/ build/ +dist/ *.egg-info/ ssl/ \ No newline at end of file diff --git a/README.md b/README.md index 3ba374c..cc56393 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,3 @@ - - # Heartbeat Daemon (hbd) โœ… A lightweight daemon that listens for UDP heartbeat messages and acts on them: keeps host state, optionally updates DNS records via `nsupdate`, forwards messages to WebSocket clients, and sends notifications (email, Pushover, Mattermost, Signal). It is a refactor of a previously monolithic script into a modular Python package (`hbd`). @@ -20,25 +18,23 @@ A lightweight daemon that listens for UDP heartbeat messages and acts on them: k ## โš™๏ธ Quickstart Prerequisites: -- Python 3.10+ (project uses language features from recent Python) + +- Python 3.10+ (project uses language features from recent Python) - `nsupdate` (for DNS updates) if using dynamic DNS Install dependencies (recommended into a venv): -```bash -python3 -m venv .venv -source .venv/bin/activate -python -m pip install --upgrade pip -python -m pip install -r requirements.txt -# for development/testing tools -python -m pip install -r requirements-dev.txt -``` +This project now declares its dependencies in `pyproject.toml`. Instead +of the old `requirements.txt` flow, install the package into a virtualenv +using `pip`: + +See `scripts/install.sh` for a way to install. Run the daemon (example): ```bash # run with default config lookup (~/.hb.yaml) -PYTHONPATH=. hbd -c .hb.yaml -f -v +hbd -c .hb.yaml -f -v ``` You can also run it directly via the package entrypoint after installation: @@ -65,7 +61,6 @@ PYTHONPATH=. python -m debugpy --listen 5678 --wait-for-client -m hbd.cli -c .hb Set breakpoints in modules such as `hbd/udp.py`, `hbd/dns.py`, or `hbd/server.py`, and use the **Attach** configuration to connect. Use `justMyCode: false` if you need to step into third-party code. - --- ## ๐Ÿ›  Configuration @@ -82,6 +77,13 @@ Set breakpoints in modules such as `hbd/udp.py`, `hbd/dns.py`, or `hbd/server.py - `interval` / `grace`: heartbeat timing configuration - `dyndomains`: list of dyndomains to update via `nsupdate` - `nsupdate_bin`: path to nsupdate binary +- `ws_port`: port for plain WebSocket connections (default: 50005) +- `wss_port`: port for secure WebSocket (WSS) connections (default: none). + If set, `hbd` will attempt to serve WSS on this port when `wss_pem` and + `wss_key` SSL files are available under `cert_path` (see below). +- `cert_path`: directory where TLS certificate and key are looked up (default: /usr/local/etc/ssl/) +- `wss_pem`: filename for the certificate chain (default: fullchain.pem) +- `wss_key`: filename for the private key (default: privkey.pem) Example `.hb.yaml` (minimal): @@ -102,7 +104,11 @@ pushsrv: pushover - `hbd.proto` โ€” serialization/deserialization of heartbeat messages (supports compressed payloads) - `hbd.udp` โ€” UDP parsing and `handle_datagram` implementation (main state machine) -- `hbd.dns` โ€” `create_nsupdate_payload`, `nsupdate`, and a background DNS thread (`start_dns_thread`) +- `hbd.dns` โ€” `create_nsupdate_payload`, `nsupdate`, and an asyncio DNS worker (`start_dns_worker`). + The DNS worker now runs as an `asyncio` task and the package exposes a + small thread-safe bridge so legacy synchronous code can `put()` updates + into the queue; there is no longer a permanently-blocking background + `threading.Thread`. - `hbd.notify` โ€” email and push notification helpers - `hbd.ws` โ€” WebSocket server and thread-safe broadcast helpers - `hbd.http` โ€” HTTP handler factory for the status UI/API @@ -112,6 +118,17 @@ pushsrv: pushover This modular layout makes the code easier to test and maintain. +**Runtime & Shutdown** + +- The main runtime is asyncio-based. Services (UDP listener, HTTP server, WebSocket server, monitor, and DNS worker) run as asyncio tasks. +- On SIGINT/SIGTERM the server triggers a graceful shutdown: it cancels active tasks, signals the DNS worker via a sentinel, and cleans up resources before exit. +- The DNS update worker is implemented as an `asyncio` task; synchronous producers can still enqueue DNS updates via a small thread-safe bridge available at `hbd.hbdclass.Host.dnsQ`. + +**Templates & Static Files** + +- Template files are located under `hbd/templates` by default. The HTTP server resolves templates relative to the `hbd` package but the path can be overridden with the `templates_dir` config key. +- Static assets (CSS/JS/images) are served from `hbd/static` via the `/static/` HTTP route. Place your static files in that directory or configure the HTTP server as needed. + --- ## ๐Ÿงช Testing & Dev @@ -126,8 +143,8 @@ pytest -q ``` Developer tooling included: + - `pyproject.toml` โ€” project metadata and dependencies -- `requirements-dev.txt` โ€” dev/test dependencies - `tox.ini` โ€” convenience wrappers for running tests, lint, and mypy To run linters and type checks locally: @@ -153,6 +170,7 @@ tox -e mypy ## ๐Ÿค Contributing Contributions welcome! Please: + 1. Open an issue to discuss larger changes. 2. Create a topic branch and a clear PR. 3. Add tests for new features and run linters. @@ -167,8 +185,8 @@ This repository is licensed under the MIT license. See `LICENSE` for details. --- If you'd like, I can also: + - add a **GitHub Actions** workflow that runs tests and lint on push/PR ๐Ÿ” - add a `CONTRIBUTING.md` template for PRs and code style ๐Ÿ’ฌ Which one should I do next? โœจ - diff --git a/dist/hbd-5.0-py3-none-any.whl b/dist/hbd-5.0-py3-none-any.whl deleted file mode 100644 index 4018a4636dd3a1d5e5140a5f5ebdfbb6f670b9ed..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 37372 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 diff --git a/hbd/cli.py b/hbd/cli.py index 82dbfd5..0ca5e55 100644 --- a/hbd/cli.py +++ b/hbd/cli.py @@ -1,4 +1,5 @@ """Command line interface for hbd package.""" + import argparse from .config import load_config @@ -13,11 +14,19 @@ def build_parser(): description="HeartBeatDaemon - Wait for heartbeat messages and act on them (or their absence)", formatter_class=argparse.RawDescriptionHelpFormatter, ) - parser.add_argument("-c", "--config", dest="configfile", help="Config file path (YAML)") - parser.add_argument("-f", "--foreground", action="store_true", help="Run in foreground") + parser.add_argument( + "-c", "--config", dest="configfile", help="Config file path (YAML)" + ) + parser.add_argument( + "-f", "--foreground", action="store_true", help="Run in foreground" + ) parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output") - parser.add_argument("-p", "--pushsrv", dest="pushsrv", choices=PUSHSRVS, help="Push service to use") - parser.add_argument("-x", "--debug", action="count", default=0, help="Increase debug level") + parser.add_argument( + "-p", "--pushsrv", dest="pushsrv", choices=PUSHSRVS, help="Push service to use" + ) + parser.add_argument( + "-x", "--debug", action="count", default=0, help="Increase debug level" + ) return parser diff --git a/hbd/config.py b/hbd/config.py index 387f248..e7fdb87 100644 --- a/hbd/config.py +++ b/hbd/config.py @@ -1,4 +1,5 @@ """Configuration loader and defaults for hbd.""" + import logging import os @@ -37,7 +38,7 @@ DEFAULTS = { "wss_port": None, "cert_path": "/usr/local/etc/ssl/", "wss_pem": "fullchain.pem", - "wss_key": "privkey.pem" + "wss_key": "privkey.pem", } @@ -54,7 +55,7 @@ def load_config(path=None): if os.path.exists(path): if yaml: with open(path) as fh: - data = yaml.safe_load(fh) + data = yaml.safe_load(fh) # only keep known keys for k, v in data.items(): if k in cfg: diff --git a/hbd/dns.py b/hbd/dns.py index 714af27..af15835 100644 --- a/hbd/dns.py +++ b/hbd/dns.py @@ -1,13 +1,23 @@ """DNS update helper and pure asyncio worker for heartbeat daemon.""" + from __future__ import annotations -import subprocess from subprocess import Popen, PIPE, STDOUT from typing import Optional import asyncio -def create_nsupdate_payload(hostname: str, newip: str, dyndomain: str, dnsttl: str = "5") -> str: - D = {"domain": dyndomain, "fqdn": f"{hostname}.dy.{dyndomain}", "dnsttl": dnsttl, "newip": newip, "ts": __import__("time").strftime("%Y-%m-%d.%H:%M:%S", __import__("time").gmtime())} +def create_nsupdate_payload( + hostname: str, newip: str, dyndomain: str, dnsttl: str = "5" +) -> str: + D = { + "domain": dyndomain, + "fqdn": f"{hostname}.dy.{dyndomain}", + "dnsttl": dnsttl, + "newip": newip, + "ts": __import__("time").strftime( + "%Y-%m-%d.%H:%M:%S", __import__("time").gmtime() + ), + } if ":" in newip: nsup = ( """update delete %(fqdn)s AAAA @@ -17,7 +27,8 @@ update add %(fqdn)s %(dnsttl)s TXT "Created: %(ts)s" send answer -""" % D +""" + % D ) else: nsup = ( @@ -28,12 +39,19 @@ update add %(fqdn)s %(dnsttl)s TXT "Created: %(ts)s" send answer -""" % D +""" + % D ) return nsup -def nsupdate(hostname: str, newip: str, dyndomain: str, nsupdate_bin: str = "/usr/local/bin/nsupdate", rndc_key: str = "/etc/dhcpc/rndc-key") -> Optional[str]: +def nsupdate( + hostname: str, + newip: str, + dyndomain: str, + nsupdate_bin: str = "/usr/local/bin/nsupdate", + rndc_key: str = "/etc/dhcpc/rndc-key", +) -> Optional[str]: """Perform DNS update via nsupdate command. Returns None on success, else returns combined stdout/stderr as a string. @@ -54,7 +72,14 @@ def nsupdate(hostname: str, newip: str, dyndomain: str, nsupdate_bin: str = "/us return out -async def dns_update_worker(hbdclass, cfg: dict, async_queue=None, log: Optional[callable] = None, pushmsg: Optional[callable] = None, loop: Optional[asyncio.AbstractEventLoop] = None): +async def dns_update_worker( + hbdclass, + cfg: dict, + async_queue=None, + log: Optional[callable] = None, + pushmsg: Optional[callable] = None, + loop: Optional[asyncio.AbstractEventLoop] = None, +): """Pure async DNS worker that processes updates from asyncio.Queue. Exits when it receives a None sentinel. @@ -66,7 +91,9 @@ async def dns_update_worker(hbdclass, cfg: dict, async_queue=None, log: Optional if not dnsq: if log: try: - await loop.run_in_executor(None, log, None, "dns_update_worker: no queue available") + await loop.run_in_executor( + None, log, None, "dns_update_worker: no queue available" + ) except Exception: pass return @@ -77,7 +104,9 @@ async def dns_update_worker(hbdclass, cfg: dict, async_queue=None, log: Optional except Exception as e: if log: try: - await loop.run_in_executor(None, log, None, f"dns_update_worker: error getting item: {e}") + await loop.run_in_executor( + None, log, None, f"dns_update_worker: error getting item: {e}" + ) except Exception: pass break @@ -96,12 +125,25 @@ async def dns_update_worker(hbdclass, cfg: dict, async_queue=None, log: Optional m = f"changed address to {addr}" for dyndomain in cfg.get("dyndomains", []): - err = await loop.run_in_executor(None, nsupdate, name, addr, dyndomain, cfg.get("nsupdate_bin", "/usr/local/bin/nsupdate"), cfg.get("rndc_key", "/etc/dhcpc/rndc-key")) + err = await loop.run_in_executor( + None, + nsupdate, + name, + addr, + dyndomain, + cfg.get("nsupdate_bin", "/usr/local/bin/nsupdate"), + cfg.get("rndc_key", "/etc/dhcpc/rndc-key"), + ) if err: m += f", DNS update failed: {err}" if pushmsg: try: - await loop.run_in_executor(None, pushmsg, "error: nsupdate failed", f"{name}.dy.{dyndomain}: {m}") + await loop.run_in_executor( + None, + pushmsg, + "error: nsupdate failed", + f"{name}.dy.{dyndomain}: {m}", + ) except Exception: pass else: @@ -125,7 +167,13 @@ async def dns_update_worker(hbdclass, cfg: dict, async_queue=None, log: Optional pass -def start_dns_worker(hbdclass, cfg: dict, log: Optional[callable] = None, pushmsg: Optional[callable] = None, loop: Optional[asyncio.AbstractEventLoop] = None): +def start_dns_worker( + hbdclass, + cfg: dict, + log: Optional[callable] = None, + pushmsg: Optional[callable] = None, + loop: Optional[asyncio.AbstractEventLoop] = None, +): """Start the async DNS worker and return the Task. Replaces Host.dnsQ with an asyncio.Queue wrapped in a thread-safe bridge @@ -139,6 +187,7 @@ def start_dns_worker(hbdclass, cfg: dict, log: Optional[callable] = None, pushms class _QueueBridge: """Thread-safe wrapper around asyncio.Queue for synchronous callers.""" + def __init__(self, loop, aq): self._loop = loop self._aq = aq @@ -167,5 +216,9 @@ def start_dns_worker(hbdclass, cfg: dict, log: Optional[callable] = None, pushms bridge = _QueueBridge(loop, async_q) hbdclass.Host.dnsQ = bridge - task = loop.create_task(dns_update_worker(hbdclass, cfg, async_queue=async_q, log=log, pushmsg=pushmsg, loop=loop)) + task = loop.create_task( + dns_update_worker( + hbdclass, cfg, async_queue=async_q, log=log, pushmsg=pushmsg, loop=loop + ) + ) return task diff --git a/hbd/hbc.py b/hbd/hbc.py index e507a29..95792ef 100755 --- a/hbd/hbc.py +++ b/hbd/hbc.py @@ -7,10 +7,7 @@ import time import socket import os import signal -import getopt -import string import select -import errno import traceback from hashlib import md5 import shutil @@ -37,13 +34,13 @@ 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: syslog.syslog(syslog.LOG_ERR, msg) @@ -115,7 +112,7 @@ class Conn: try: self.lastack = msgDict["time"] mul = 2 - except: + except Exception: self.lastack = now mul = 1 rtt = (self.lastack - self.lastsend) * mul @@ -140,7 +137,7 @@ def shortname(name): def dicttos(ID, d): s = [] for k in d: - if type(d[k]) == type(1.2): + if isinstance(d[k], float): s.append("%s=%0.5f" % (k, d[k])) else: s.append("%s=%s" % (k, d[k])) @@ -169,7 +166,7 @@ def stodict(msg): v = vr[1].strip() try: v = eval(v) - except: + except Exception: pass d[k] = v if verbose: @@ -199,7 +196,7 @@ def XXstodict(msg): try: if v[0].isdigit(): v = eval(v) - except: + except Exception: pass d[k] = v return d @@ -208,8 +205,8 @@ def XXstodict(msg): def syslogtrace(note): logm = "%s hbc died: \n%s" % (note, traceback.format_exc()) log(logm) - for l in logm.split("\n"): - syslog.syslog(syslog.LOG_ERR, " tb: %s" % l) + for line in logm.split("\n"): + syslog.syslog(syslog.LOG_ERR, " tb: %s" % line) if verbose: print(logm) @@ -314,7 +311,7 @@ def restart(): e = "fallthrough" try: os.execv(sys.argv[0], [sys.argv[0]] + cmdargs) - except Exception as e: + except Exception: pass print("should not be here:", str(e)) log("restart failed: %s" % e) @@ -350,7 +347,7 @@ def process(): if running: running = False break - except: + except Exception: if running: syslogtrace("select") running = False @@ -374,12 +371,12 @@ def process(): "sock.recvfrom: %s (%s) %s" % (addr, len(data), str(msgDict)[:80]) ) - if msgDict == None: + if msgDict is None: print("bad backet from %s (%s) %s" % (addr, len(data), data)) elif msgDict["ID"] == "ACK": conns[conn].ack(msgDict, now) elif msgDict["ID"] == "UPD": - if doupdate(conn, msgDict) == None: + if doupdate(conn, msgDict) is None: if verbose: print("process: restart after update") dorestart = True @@ -473,6 +470,7 @@ def daemonize( os.dup2(so.fileno(), sys.stdout.fileno()) os.dup2(se.fileno(), sys.stderr.fileno()) + # # Main program # @@ -483,46 +481,55 @@ def build_parser(): 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( + "-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( + "-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( + "-x", "--debug", action="count", default=0, help="Increase debug level" + ) parser.add_argument("hosts", nargs="+", help="Heartbeat daemon hosts to send to") return parser + def main(argv=None): - global msgonly, helpflag, verbose, fdaemon, daemonized, optlist, msgboot, home, configfile, cmdargs, iam, hb_port, conns, interval, hb_hosts + global msgonly, verbose, fdaemon, daemonized, cmdargs, iam, hb_port, conns, interval, hb_hosts parser = build_parser() - args = parser.parse_args(argv) - + args = parser.parse_args(argv) + config = load_config(args.configfile) # Apply CLI overrides if args.boot: - msgboot["boot"] = 1 + msgboot["boot"] = 1 if args.message: - msgboot["service"] = "service" - msgboot["msg"] = args.message - msgonly = True + msgboot["service"] = "service" + msgboot["msg"] = args.message + msgonly = True if args.name: - iam = args.name + iam = args.name if args.daemon: - fdaemon = True + fdaemon = True if args.verbose: - verbose = True + verbose = True if args.debug: - config.setdefault("debug", 0) - config["debug"] += args.debug + config.setdefault("debug", 0) + config["debug"] += args.debug cmdargs += argv if verbose: 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) @@ -535,10 +542,10 @@ def main(argv=None): 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: @@ -549,23 +556,23 @@ def main(argv=None): 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: @@ -573,21 +580,21 @@ def main(argv=None): 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() - + + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/hbd/hbdclass.py b/hbd/hbdclass.py index 5c9698e..f6fe806 100644 --- a/hbd/hbdclass.py +++ b/hbd/hbdclass.py @@ -93,18 +93,18 @@ class Connection: if self.state == Connection.UNKNOWN: d["deltastatetime"] = "" elif delta > 86400: - # d['deltastatetime'] = time.strftime("%d %H:%M:%S", time.gmtime(delta)) + # 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("%H:%M:%S", time.gmtime(delta)) d["deltastatetime"] = time.strftime("%k:%M hrs", time.gmtime(delta)) - # d['deltastatetime'] = "%0.1f hrs" % (delta / 3600.) + # 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", time.gmtime(delta)) d["deltastatetime"] = time.strftime("%M:%S mins", time.gmtime(delta)) - # d['deltastatetime'] = "%0.1f mins" % (delta / 60.) + # d['deltastatetime'] = "%0.1f mins" % (delta / 60.) else: - # d['deltastatetime'] = time.strftime("%S", time.gmtime(delta)) + # 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() @@ -148,7 +148,7 @@ class Connection: r = "changed from %s to %s" % (self.addr, addr) try: del Connection.htab[self.addr] - except: + except Exception: pass self.addr = addr Connection.htab[addr] = self.host.name @@ -293,7 +293,6 @@ class Host: 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] @@ -372,7 +371,7 @@ class Host: res = [] le = max(40 - len(Host.hosts), 3) res.append("

Log of Events

") - for m in msgs[len(msgs) - le:]: + for m in msgs[len(msgs) - le :]: res.append("%s
" % m) return res diff --git a/hbd/http.py b/hbd/http.py index e96ab77..d1e2e12 100644 --- a/hbd/http.py +++ b/hbd/http.py @@ -1,4 +1,5 @@ """HTTP server implementation using aiohttp and jinja2.""" + import asyncio import json import time @@ -6,15 +7,16 @@ import urllib.parse import os import logging from aiohttp import web -from fastapi.templating import Jinja2Templates import jinja2 logger = logging.getLogger(__name__) + def _render_template(html_str: str, **context) -> str: tmpl = jinja2.Template(html_str) return tmpl.render(**context) + async def start( host: str, port: int, @@ -42,7 +44,7 @@ async def start( res.append('') res.append("") res.append("") - res.append(f"Heartbeat") + res.append("Heartbeat") if tcss: res.append(tcss) res.append("") @@ -51,7 +53,11 @@ async def start( res += hbdclass.ubHost.buildhosttable() res += hbdclass.ubHost.buildmsgtable(msgs_getter()) res.append( - "

%s (%s)

" % (time.strftime("%H:%M:%S", time.localtime(get_now())), config.get("tz", "CET-1CDT")) + "

%s (%s)

" + % ( + time.strftime("%H:%M:%S", time.localtime(get_now())), + config.get("tz", "CET-1CDT"), + ) ) res.append("") body = "\n".join(res) @@ -73,7 +79,9 @@ async def start( return web.Response(status=400, text="need h= and c= arguments") if uname not in hbdclass.Host.hosts: return web.Response(status=400, text=f"h={uname} not found") - hbdclass.Host.hosts[uname].cmds.append(("CMD", {"cmd": urllib.parse.unquote(ucmd)})) + hbdclass.Host.hosts[uname].cmds.append( + ("CMD", {"cmd": urllib.parse.unquote(ucmd)}) + ) return web.Response(text=f"cmd {uname} queued") async def drop(request): @@ -150,7 +158,9 @@ async def start( request=request, heartbeat_ws_url=heartbeat_ws_url, extra_scripts=extra_scripts, - hosts=[hbdclass.Host.hosts[h].stateinfo() for h in sorted(hbdclass.Host.hosts)], + hosts=[ + hbdclass.Host.hosts[h].stateinfo() for h in sorted(hbdclass.Host.hosts) + ], messages=msgs_getter()[-30:], ) return web.Response(text=body, content_type="text/html") @@ -209,4 +219,3 @@ async def start( await asyncio.Future() finally: await runner.cleanup() - diff --git a/hbd/monitor.py b/hbd/monitor.py index 9ee1d88..f459689 100644 --- a/hbd/monitor.py +++ b/hbd/monitor.py @@ -1,15 +1,19 @@ """monitor helper and thread for heartbeat daemon.""" + from __future__ import annotations import asyncio -import threading -import subprocess import time -from subprocess import Popen, PIPE, STDOUT -from typing import Optional -from . import hbdclass + DROPOVERDUE = 7 * 24 * 3600 -def checkoverdue(config: dict, hbdclass, log: callable, pushmsg: callable, msg_to_websockets: callable): + +def checkoverdue( + config: dict, + hbdclass, + log: callable, + pushmsg: callable, + msg_to_websockets: callable, +): now = time.time() for h in list(hbdclass.Host.hosts.keys()): pmsg = [] @@ -22,7 +26,8 @@ def checkoverdue(config: dict, hbdclass, log: callable, pushmsg: callable, msg_t conn.newstate(hbdclass.Connection.OVERDUE, now, config.get("grace", 10)) pmsg.append(conn.afam) if ( - conn.state == hbdclass.Connection.OVERDUE and (now - conn.lastbeat) > DROPOVERDUE + conn.state == hbdclass.Connection.OVERDUE + and (now - conn.lastbeat) > DROPOVERDUE ): conn.newstate(hbdclass.Connection.UNKNOWN, conn.lastbeat) if pmsg != []: @@ -31,6 +36,7 @@ def checkoverdue(config: dict, hbdclass, log: callable, pushmsg: callable, msg_t log(h, "%s overdue" % " and ".join(pmsg)) msg_to_websockets("host", hbdclass.Host.hosts[h].stateinfo()) + async def start( config: dict, hbdclass: callable, @@ -38,7 +44,7 @@ async def start( pushmsg=None, msg_to_websockets=None, ): - """ start a monitor loop that checks for overdue hosts every minute """ + """start a monitor loop that checks for overdue hosts every minute""" while True: - await asyncio.sleep(15) # 15 seconds between checks + await asyncio.sleep(15) # 15 seconds between checks checkoverdue(config, hbdclass, log, pushmsg, msg_to_websockets) diff --git a/hbd/notify.py b/hbd/notify.py index d78cc93..5ea95ec 100644 --- a/hbd/notify.py +++ b/hbd/notify.py @@ -1,4 +1,5 @@ """Notification helpers: email, pushover, mattermost, signal and dispatcher.""" + import logging from typing import Optional import http.client @@ -6,7 +7,6 @@ import urllib.parse import subprocess import smtplib import time -import traceback DEFAULT_PUSHPROVIDERS = ["all", "pushover", "mattermost", "signal"] @@ -24,7 +24,7 @@ def setup(cfg: dict): def send_email(toaddrs, smtpserver, sender, subject, body, debug=0): """Send a plain email via SMTP. Returns True on success.""" try: - smtpport = _config.get("smtpport", 587) + smtpport = _config.get("smtpport", 587) server = smtplib.SMTP(smtpserver, smtpport) if debug > 0: server.set_debuglevel(1) @@ -57,10 +57,15 @@ def email(subject: str, msg: str, debug: int = 0) -> bool: and sender address. """ toaddrs = _config.get("toemail") - fromemail = _config.get("fromemail") - smtpserver = _config.get("smtpserver") + fromemail = _config.get("fromemail") + smtpserver = _config.get("smtpserver") if not toaddrs or not fromemail or not smtpserver: - logger.warning("email config incomplete: toemail=%s, fromemail=%s, smtpserver=%s", toaddrs, fromemail, smtpserver) + logger.warning( + "email config incomplete: toemail=%s, fromemail=%s, smtpserver=%s", + toaddrs, + fromemail, + smtpserver, + ) return False date = time.strftime("%a, %d %b %Y %H:%M:%S %z", time.localtime()) body = "To: %s\nFrom: %s\nSubject: %s\nDate: %s\n\n%s" % ( @@ -91,7 +96,15 @@ def pushover(token: str, user: str, msg: str, debug: int = 0) -> bool: return False -def pushmattermost(host: str, token: str, channel: str, msg: str, username: str = "hbd", icon: Optional[str] = None, debug: int = 0) -> bool: +def pushmattermost( + host: str, + token: str, + channel: str, + msg: str, + username: str = "hbd", + icon: Optional[str] = None, + debug: int = 0, +) -> bool: """Send a message to Mattermost via simple webhook driver if available. This helper tries to import mattermostdriver.Driver and uses webhooks if present. @@ -115,7 +128,9 @@ def pushmattermost(host: str, token: str, channel: str, msg: str, username: str return False -def pushsignal(signal_cli_bin: str, user: str, recipient: str, msg: str, debug: int = 0) -> bool: +def pushsignal( + signal_cli_bin: str, user: str, recipient: str, msg: str, debug: int = 0 +) -> bool: """Send a message via signal-cli (requires local installation). Uses subprocess to call signal-cli. Returns True if the command succeeded. @@ -125,7 +140,7 @@ def pushsignal(signal_cli_bin: str, user: str, recipient: str, msg: str, debug: try: res = subprocess.run(CLI, capture_output=True) if res.returncode != 0: - logger.error("signal failed: %s". res.stderr.decode()) + logger.error("signal failed: %s".res.stderr.decode()) return False logger.debug("signal sent: %s", res.stdout.decode()) return True @@ -148,13 +163,32 @@ def pushmsg(cfg: dict, msg: str, debug: int = 0): results = {} p = cfg.get("pushsrv", "pushover") if p in ("all", "pushover"): - ok = pushover(cfg.get("pushover_token", ""), cfg.get("pushover_user", ""), msg, debug=debug) + ok = pushover( + cfg.get("pushover_token", ""), + cfg.get("pushover_user", ""), + msg, + debug=debug, + ) results["pushover"] = ok if p in ("all", "mattermost"): - ok = pushmattermost(cfg.get("matter_host", ""), cfg.get("matter_token", ""), cfg.get("matter_channel", ""), msg, username=cfg.get("matter_username", "hbd"), icon=cfg.get("matter_icon"), debug=debug) + ok = pushmattermost( + cfg.get("matter_host", ""), + cfg.get("matter_token", ""), + cfg.get("matter_channel", ""), + msg, + username=cfg.get("matter_username", "hbd"), + icon=cfg.get("matter_icon"), + debug=debug, + ) results["mattermost"] = ok if p in ("all", "signal"): - ok = pushsignal(cfg.get("signal_cli", "/usr/local/bin/signal-cli"), cfg.get("signal_user", ""), cfg.get("signal_recipient", ""), msg, debug=debug) + ok = pushsignal( + cfg.get("signal_cli", "/usr/local/bin/signal-cli"), + cfg.get("signal_user", ""), + cfg.get("signal_recipient", ""), + msg, + debug=debug, + ) results["signal"] = ok if p in ("all", "email"): ok = email("Heartbeat notification", msg, debug=debug) @@ -166,4 +200,3 @@ def pushmsg(cfg: dict, msg: str, debug: int = 0): def pushmsg_from_config(msg: str, debug: int = 0) -> dict: """Use the module-level configuration dict to dispatch a push message.""" return pushmsg(_config, msg, debug=debug) - diff --git a/hbd/proto.py b/hbd/proto.py index 8212960..375748d 100644 --- a/hbd/proto.py +++ b/hbd/proto.py @@ -1,4 +1,5 @@ """Message encoding/decoding utilities for hbd protocol.""" + from typing import Dict, Any import zlib diff --git a/hbd/server.py b/hbd/server.py index 4b353e6..488d7b4 100644 --- a/hbd/server.py +++ b/hbd/server.py @@ -1,4 +1,5 @@ """Server runtime: starts UDP listener, HTTP server and websocket stubs.""" + import asyncio import logging import socket @@ -6,7 +7,6 @@ import time import signal import sys import ssl -import pathlib from . import __version__ from . import udp @@ -23,14 +23,17 @@ lastfm = ["", "", ""] # shared runtime collections and helpers msgs = [] + def initlog(logfile): try: return open(logfile, "a+") except Exception as e: import sys + print("cannot open loffile %s, using STDERR: %s" % (logfile, e)) return sys.stderr + def log(host, m, service=None): ts = time.time() s = f"{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(ts))} {host or ''} {m}" @@ -44,10 +47,12 @@ def log(host, m, service=None): logger.warning("failed to write to logfile: %s", e) msg_to_websockets("message", s) + def cleanup_function(config): """This function will be executed upon program exit.""" logger.info("Running cleanup function...") import pickle + pickfile = config.get("pickfile", "hbd.pickle") pickf = open(pickfile, "wb") @@ -56,17 +61,17 @@ def cleanup_function(config): pick.dump(msgs) pick.dump(lastfm) pickf.close() - + logger.info("Cleanup complete.") - + + async def _run_async(config): - global msgs loop = asyncio.get_running_loop() shutdown_event = asyncio.Event() # Signal handlers for graceful shutdown def signal_handler(signum, frame): - sig_name = signal.Signals(signum).name if hasattr(signal, 'Signals') else signum + sig_name = signal.Signals(signum).name if hasattr(signal, "Signals") else signum logger.info(f"Received {sig_name}, initiating shutdown...") loop.call_soon_threadsafe(shutdown_event.set) @@ -74,13 +79,10 @@ async def _run_async(config): loop.add_signal_handler(signal.SIGINT, signal_handler, signal.SIGINT, None) loop.add_signal_handler(signal.SIGTERM, signal_handler, signal.SIGTERM, None) - # prepare runtime dependencies - import threading -# from . import hbdclass from . import http as http_mod from . import dns as dns_mod from . import notify as notify_mod - from . import monitor as monitor_mod + from . import monitor as monitor_mod notify_mod.setup(config) @@ -93,7 +95,9 @@ async def _run_async(config): try: sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, False) except OSError as e: - logger.error(f"Warning: Could not set IPV6_V6ONLY to False. System may not support dual-stack or option is unavailable. Error: {e}") + logger.warning( + f"Warning: Could not reset IPV6_V6ONLY not supported or dual-stack is unavailable. Error: {e}" + ) # 3. Bind to all interfaces (::) on a specific port @@ -138,14 +142,20 @@ async def _run_async(config): VER="", ) ) - logger.info("HTTP server started on %s:%s", config.get("hbd_host", ""), config.get("hbd_port", 50004)) + logger.info( + "HTTP server started on %s:%s", + config.get("hbd_host", ""), + config.get("hbd_port", 50004), + ) except Exception as e: logger.exception("failed to start HTTP server: %s", e) # start dns update worker (async) dns_task = None try: - dns_task = dns_mod.start_dns_worker(hbdclass, config, log=log, pushmsg=pushmsg, loop=loop) + dns_task = dns_mod.start_dns_worker( + hbdclass, config, log=log, pushmsg=pushmsg, loop=loop + ) logger.info("dns update worker started") except Exception as e: logger.exception("dns worker failed to start: %s", e) @@ -161,7 +171,11 @@ async def _run_async(config): except FileNotFoundError: logger.error("error: missing SSL keys %s or %s", wss_pem, wss_key) sys.exit(1) - logger.info("Starting secure WebSocket server on port %s with cert %s", config.get("wss_port", None), wss_pem) + logger.info( + "Starting secure WebSocket server on port %s with cert %s", + config.get("wss_port", None), + wss_pem, + ) else: ssl_context = None @@ -172,7 +186,10 @@ async def _run_async(config): ws_port=config.get("ws_port", None), wss_port=config.get("wss_port", None), ssl_context=ssl_context, - get_hosts=lambda: [hbdclass.Host.hosts[h].stateinfo() for h in sorted(hbdclass.Host.hosts)], + get_hosts=lambda: [ + hbdclass.Host.hosts[h].stateinfo() + for h in sorted(hbdclass.Host.hosts) + ], get_msgs=lambda: msgs, verbose=config.get("verbose", False), ) @@ -209,7 +226,7 @@ async def _run_async(config): transport.close() except Exception as e: logger.warning("Error closing UDP transport: %s", e) - + tasks_to_cancel = [http_task, ws_task, monitor_task] for task in tasks_to_cancel: if task: @@ -218,20 +235,23 @@ async def _run_async(config): logger.debug("Cancelled task: %s", task) except Exception as e: logger.warning("Error cancelling task: %s", e) - + # Wait for tasks to finish cancellation with timeout remaining_tasks = [t for t in tasks_to_cancel if t] if remaining_tasks: try: - await asyncio.wait_for(asyncio.gather(*remaining_tasks, return_exceptions=True), timeout=2.0) + await asyncio.wait_for( + asyncio.gather(*remaining_tasks, return_exceptions=True), + timeout=2.0, + ) except asyncio.TimeoutError: logger.warning("Timeout waiting for tasks to cancel") except Exception as e: logger.debug("Exception during task cancellation: %s", e) - + # Signal DNS worker to exit and await it try: - if 'dns_task' in locals() and dns_task: + if "dns_task" in locals() and dns_task: try: hbdclass.Host.dnsQ.put(None) except Exception: @@ -270,12 +290,12 @@ def load_pickled_hosts(config, hbdclass): logger.info("opening pickls %s", pickfile) pickf = open(pickfile, "rb") pick = pickle.Unpickler(pickf) - try: + try: hbdclass.Host.hosts = pick.load() msgs = pick.load() - try: + try: lastfm = pick.load() - except: + except Exception: lastfm = ["", "", ""] pickf.close() except Exception as e: @@ -295,6 +315,7 @@ def load_pickled_hosts(config, hbdclass): if config.get("verbose", False): logger.info("no pickled data") + def run(config): """Start the hbd service (blocking). @@ -302,19 +323,19 @@ def run(config): """ global logf import os - import threading - import time as time_module - logging.basicConfig(level=logging.DEBUG if config.get("debug", 0) > 0 else logging.INFO) + logging.basicConfig( + level=logging.DEBUG if config.get("debug", 0) > 0 else logging.INFO + ) load_pickled_hosts(config, hbdclass) logf = initlog(logfile=config.get("logfile", "messages.log")) log(None, f"hbd version {__version__} starting up") - + # Create and set the event loop manually loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) - + try: loop.run_until_complete(_run_async(config)) except KeyboardInterrupt: @@ -337,11 +358,13 @@ def run(config): task.cancel() # Run one more cycle to process cancellations if pending: - loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) + loop.run_until_complete( + asyncio.gather(*pending, return_exceptions=True) + ) except Exception: pass finally: loop.close() - + # Exit os._exit(0) diff --git a/hbd/udp.py b/hbd/udp.py index c29dfaa..cd21470 100644 --- a/hbd/udp.py +++ b/hbd/udp.py @@ -1,14 +1,14 @@ """UDP listener and datagram processing.""" + import asyncio import zlib import logging -logger = logging.getLogger(__name__) - - from .proto import stodict, oldmtodict from hbd.utils import dur +logger = logging.getLogger(__name__) + class EchoServerProtocol(asyncio.DatagramProtocol): def __init__(self, config=None, handler=None): @@ -44,6 +44,7 @@ def parse_message(data: bytes): msg = oldmtodict(data) return msg + def dicttos(ID, d, compress=False): s = [] for k in d: @@ -61,6 +62,7 @@ def dicttos(ID, d, compress=False): opk = ID + ":" + zpk return opk + def handle_datagram(msg: dict, addr, transport, ctx: dict): """Handle a parsed datagram message. @@ -87,6 +89,7 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict): ip = addr[0] if isinstance(addr, (list, tuple)) else addr name = msg.get("name", "unknown") from hbd.utils import shortname + uname = shortname(name) if uname not in hbdcls.Host.hosts: @@ -215,5 +218,3 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict): msg_to_websockets("host", host.stateinfo()) except Exception: pass - - diff --git a/hbd/utils.py b/hbd/utils.py index 7188dd9..3eb06e1 100644 --- a/hbd/utils.py +++ b/hbd/utils.py @@ -1,5 +1,4 @@ """Utility helpers extracted from the original script.""" -import time def shortname(name: str) -> str: diff --git a/hbd/ws.py b/hbd/ws.py index 8beb0c7..0bf488f 100644 --- a/hbd/ws.py +++ b/hbd/ws.py @@ -3,6 +3,7 @@ Provides an asyncio-based WebSocket server and a thread-safe broadcast function that other threads or synchronous code can call. """ + import asyncio import json import logging @@ -20,7 +21,6 @@ _verbose = False async def _handler(websocket, path=None): - global _connections _connections.add(websocket) remote_address = websocket.remote_address if path is None: @@ -46,7 +46,10 @@ async def _handler(websocket, path=None): if _verbose: logger.debug("received ws data: %s", _) - except (websockets.exceptions.ConnectionClosedOK, websockets.exceptions.ConnectionClosedError) as e: + except ( + websockets.exceptions.ConnectionClosedOK, + websockets.exceptions.ConnectionClosedError, + ) as e: if _verbose: logger.info("ws closed: %r", e) except Exception as e: @@ -59,7 +62,15 @@ async def _handler(websocket, path=None): await websocket.wait_closed() -async def start(host: str, ws_port: int, wss_port: Optional[int] = None, ssl_context=None, get_hosts: Optional[Callable] = None, get_msgs: Optional[Callable] = None, verbose: bool = False): +async def start( + host: str, + ws_port: int, + wss_port: Optional[int] = None, + ssl_context=None, + get_hosts: Optional[Callable] = None, + get_msgs: Optional[Callable] = None, + verbose: bool = False, +): """Start WebSocket servers and block until cancelled. This is intended to be awaited inside the main asyncio event loop. @@ -77,11 +88,13 @@ async def start(host: str, ws_port: int, wss_port: Optional[int] = None, ssl_con websockets_logger = logging.getLogger("websockets.server") websockets_logger.setLevel(logging.DEBUG if verbose else logging.INFO) # regular WebSocket - ws_server = websockets.serve(_handler, host, ws_port) #, subprotocols=["hbd"]) + ws_server = websockets.serve(_handler, host, ws_port) # , subprotocols=["hbd"]) servers.append(ws_server) # secure WebSocket (optional) if wss_port and ssl_context: - wss_server = websockets.serve(_handler, host, wss_port, ssl=ssl_context ) #, subprotocols=["hbd"]) + wss_server = websockets.serve( + _handler, host, wss_port, ssl=ssl_context + ) # , subprotocols=["hbd"]) servers.append(wss_server) # await starting of all servers @@ -89,7 +102,9 @@ async def start(host: str, ws_port: int, wss_port: Optional[int] = None, ssl_con await srv if _verbose: - logger.info("WebSocket server(s) started on port %s (wss %s)", ws_port, wss_port) + logger.info( + "WebSocket server(s) started on port %s (wss %s)", ws_port, wss_port + ) # block forever (until loop is stopped or cancelled) await asyncio.Future() @@ -101,8 +116,6 @@ def broadcast(typ: str, data) -> bool: Schedules coroutine(s) on the running loop to send message to all connected websockets. Returns False if server was not running. """ - global _loop - if not _loop: return False jmsg = json.dumps({"type": typ, "data": data}) diff --git a/pyproject.toml b/pyproject.toml index 21816f7..9551dd6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,3 +45,13 @@ include = ["hbd*"] [tool.setuptools.package-data] "hbd" = ["*.yaml", "static/*", "static/*/*", "templates/*"] + + +[tool.black] +line-length = 111 + +[tool.flake8] +max-line-length = 111 + +[tool.pylint.format] +max-line-length = 111 diff --git a/install.sh b/scripts/install.sh similarity index 100% rename from install.sh rename to scripts/install.sh diff --git a/tests/test_dns.py b/tests/test_dns.py index d0cf7d2..db648be 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -47,7 +47,13 @@ class TestDNS(unittest.TestCase): proc.communicate.return_value = (b"some error", None) mock_popen.return_value = proc - err = dns.nsupdate("host", "1.2.3.4", "example", nsupdate_bin="/usr/bin/nsupdate", rndc_key="/etc/rndc.key") + err = dns.nsupdate( + "host", + "1.2.3.4", + "example", + nsupdate_bin="/usr/bin/nsupdate", + rndc_key="/etc/rndc.key", + ) self.assertIsNotNone(err) self.assertIn("some error", err) @@ -71,7 +77,9 @@ class TestDNS(unittest.TestCase): Host = FakeHost # start the thread (daemon) that processes the queue - t = dns.start_dns_thread(FakeHbd, {"dyndomains": ["example"]}, log=log, email=email) + t = dns.start_dns_thread( + FakeHbd, {"dyndomains": ["example"]}, log=log, email=email + ) self.assertTrue(t.is_alive()) # enqueue one item and wait for it to be processed (polling with timeout) @@ -83,7 +91,9 @@ class TestDNS(unittest.TestCase): time.sleep(0.1) self.assertTrue(logs, "dnsupdatethread did not call log") - self.assertTrue(any("changed address" in m or "DNS updated" in m for (_h, m) in logs)) + self.assertTrue( + any("changed address" in m or "DNS updated" in m for (_h, m) in logs) + ) def test_dnsupdatethread_calls_email_on_failure(self): # patch nsupdate to fail with an error message @@ -104,7 +114,9 @@ class TestDNS(unittest.TestCase): class FakeHbd: Host = FakeHost - t = dns.start_dns_thread(FakeHbd, {"dyndomains": ["example"]}, log=log, email=email) + dns.start_dns_thread( + FakeHbd, {"dyndomains": ["example"]}, log=log, email=email + ) # enqueue and wait for the email to be sent FakeHbd.Host.dnsQ.put(("testhost", "1.2.3.4")) @@ -114,12 +126,23 @@ class TestDNS(unittest.TestCase): time.sleep(0.1) self.assertTrue(emails, "dnsupdatethread did not call email on failure") - self.assertTrue(any("nsupdate failed" in s or "nsupdate failed" in m or "error" in m for (s, m) in emails)) + self.assertTrue( + any( + "nsupdate failed" in s or "nsupdate failed" in m or "error" in m + for (s, m) in emails + ) + ) @patch("hbd.dns.Popen") def test_nsupdate_raises_oserror(self, mock_popen): mock_popen.side_effect = OSError("noexec") - err = dns.nsupdate("h", "1.2.3.4", "example", nsupdate_bin="/usr/bin/nsupdate", rndc_key="/etc/rndc.key") + err = dns.nsupdate( + "h", + "1.2.3.4", + "example", + nsupdate_bin="/usr/bin/nsupdate", + rndc_key="/etc/rndc.key", + ) self.assertIsNotNone(err) self.assertIn("execution failed", err) diff --git a/tests/test_handle_datagram.py b/tests/test_handle_datagram.py index 7c686b6..1e6ea0a 100644 --- a/tests/test_handle_datagram.py +++ b/tests/test_handle_datagram.py @@ -1,9 +1,11 @@ from hbd.udp import handle_datagram, parse_message from hbd.proto import dicttos + class FakeTransport: def __init__(self): self.sent = [] + def sendto(self, data, addr): self.sent.append((data, addr)) @@ -18,30 +20,30 @@ def test_handle_cmd_sends_command(): import hbdclass ctx = { - 'config': {'watchhosts':[], 'dyndnshosts':[]}, - 'hbdclass': hbdclass, - 'log': dummy_noop, - 'email': dummy_noop, - 'pushmsg': dummy_noop, - 'msg_to_websockets': dummy_noop, - 'msgs': [], - 'DEBUG': 0, - 'verbose': False, + "config": {"watchhosts": [], "dyndnshosts": []}, + "hbdclass": hbdclass, + "log": dummy_noop, + "email": dummy_noop, + "pushmsg": dummy_noop, + "msg_to_websockets": dummy_noop, + "msgs": [], + "DEBUG": 0, + "verbose": False, } # create host by sending initial heartbeat - msg = parse_message(dicttos('HTB', {'name':'cmdhost','interval':10})) - handle_datagram(msg, ('127.0.0.1',50000), ftr, ctx) - assert ftr.sent[0][0] == b'ACK' + msg = parse_message(dicttos("HTB", {"name": "cmdhost", "interval": 10})) + handle_datagram(msg, ("127.0.0.1", 50000), ftr, ctx) + assert ftr.sent[0][0] == b"ACK" # queue a CMD for the host and send another heartbeat; expect command sent - h = hbdclass.Host.hosts['cmdhost'] - h.cmds.append(('CMD', {'cmd': 'doit'})) + h = hbdclass.Host.hosts["cmdhost"] + h.cmds.append(("CMD", {"cmd": "doit"})) ftr.sent.clear() - msg2 = parse_message(dicttos('HTB', {'name':'cmdhost','interval':10})) - handle_datagram(msg2, ('127.0.0.1',50000), ftr, ctx) + msg2 = parse_message(dicttos("HTB", {"name": "cmdhost", "interval": 10})) + handle_datagram(msg2, ("127.0.0.1", 50000), ftr, ctx) # should have sent ACK and the command; last send should be non-empty assert len(ftr.sent) >= 1 # the command for cver 0 will be sent as raw cmd string # so at least one send contains b'doit' or similar - assert any(b'doit' in s[0] for s in ftr.sent) + assert any(b"doit" in s[0] for s in ftr.sent) diff --git a/tests/test_proto.py b/tests/test_proto.py index 16a4044..2135d75 100644 --- a/tests/test_proto.py +++ b/tests/test_proto.py @@ -1,4 +1,3 @@ -import pytest from hbd.proto import dicttos, stodict, oldmtodict diff --git a/tests/test_udp.py b/tests/test_udp.py index 3495980..23a887c 100644 --- a/tests/test_udp.py +++ b/tests/test_udp.py @@ -3,12 +3,12 @@ from hbd.proto import dicttos def test_parse_message_uncompressed(): - raw = dicttos('HTB', {'name': 'host', 'interval': 1}) + raw = dicttos("HTB", {"name": "host", "interval": 1}) m = parse_message(raw) - assert m['ID'].startswith('HTB') + assert m["ID"].startswith("HTB") def test_parse_message_compressed(): - raw = dicttos('ACK', {'time': 1}, compress=True) + raw = dicttos("ACK", {"time": 1}, compress=True) m = parse_message(raw) - assert 'ID' in m + assert "ID" in m diff --git a/tox.ini b/tox.ini index ad7c1ef..6a2a3d1 100644 --- a/tox.ini +++ b/tox.ini @@ -22,5 +22,5 @@ commands = mypy hbd [flake8] -max-line-length = 88 +max-line-length = 111 extend-ignore = E203