Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e2038ecac | |||
| 75e41eafc4 | |||
| 73b9d05357 | |||
| 9d81f96f31 | |||
| d2e1c7a629 | |||
| 83d5ead471 | |||
| d339133981 | |||
| 7be129ad40 | |||
| 179048e565 | |||
| 8fe64ae8c5 | |||
| b6574872cc | |||
| 5e6dfc75ad | |||
| 087a264e97 | |||
| d9ca0b74e2 | |||
| 999740bc99 | |||
| 4c53b7cec9 | |||
| 535b839bfc | |||
| e3dd461d04 | |||
| e55a81568f | |||
| 83fbba433e | |||
| a494b162cd | |||
| 83b7139643 | |||
| 5dca9369dd |
@@ -7,4 +7,7 @@ __pycache__/
|
||||
.venv/
|
||||
test/
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
ssl/
|
||||
uv.lock
|
||||
|
||||
@@ -10,7 +10,9 @@ watchhosts:
|
||||
# "localhost":
|
||||
# "haschloss" :
|
||||
# "cotgate":
|
||||
# "wentworth":
|
||||
"wentworth":
|
||||
notify: +4915123456789
|
||||
src: "signal"
|
||||
"y":
|
||||
notify: +4915123456789
|
||||
src: "signal"
|
||||
@@ -25,3 +27,18 @@ pushover_user: "uDhH33UjQQDYtNzJb1ThRiWb9ingGK"
|
||||
pushsrv: "pushover"
|
||||
|
||||
dyndomains: {"wrede.org"}
|
||||
toemail: ["aew.hbd.notify@wrede.ca"]
|
||||
fromemail: "aew.hbd@wrede.ca"
|
||||
smtpserver: "smtp.fastmail.com"
|
||||
smtpuser: "andreas@wrede.ca"
|
||||
smtppassword: "r8psra6wj6gcakkp"
|
||||
smtpport: 587
|
||||
|
||||
ws_port: 50005
|
||||
wss_port: 50006
|
||||
cert_path: "/usr/local/etc/letsencrypt/live/hbd.wrede.ca/"
|
||||
cert_path: "ssl/"
|
||||
# CERT_PATH = "./test/"
|
||||
wss_pem: "fullchain.pem"
|
||||
wss_key: "privkey.pem"
|
||||
|
||||
|
||||
@@ -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)
|
||||
- `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/<path>` 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? ✨
|
||||
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
+1
-1
@@ -6,6 +6,6 @@ start moving functionality into the package.
|
||||
"""
|
||||
|
||||
__all__ = ["main", "__version__"]
|
||||
__version__ = "5.0"
|
||||
__version__ = "5.0.5"
|
||||
|
||||
from .cli import main
|
||||
|
||||
+13
-4
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Configuration loader and defaults for hbd."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
@@ -27,6 +28,17 @@ DEFAULTS = {
|
||||
"foreground": False,
|
||||
"verbose": False,
|
||||
"debug": 0,
|
||||
"smtpserver": "smtp.fastmail.com",
|
||||
"smtpuser": "andreas@wrede.ca",
|
||||
"smtppassword": "pvtvefyp5gbhnch2",
|
||||
"smtpport": 587,
|
||||
"toemail": ["aew.hbd.notify@wrede.ca"],
|
||||
"fromemail": "aew.hbd@wrede.ca",
|
||||
"ws_port": 50005,
|
||||
"wss_port": None,
|
||||
"cert_path": "/usr/local/etc/ssl/",
|
||||
"wss_pem": "fullchain.pem",
|
||||
"wss_key": "privkey.pem",
|
||||
}
|
||||
|
||||
|
||||
|
||||
+67
-14
@@ -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, email: 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 email:
|
||||
if pushmsg:
|
||||
try:
|
||||
await loop.run_in_executor(None, email, "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, email: 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, email:
|
||||
|
||||
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, email:
|
||||
bridge = _QueueBridge(loop, async_q)
|
||||
hbdclass.Host.dnsQ = bridge
|
||||
|
||||
task = loop.create_task(dns_update_worker(hbdclass, cfg, async_queue=async_q, log=log, email=email, loop=loop))
|
||||
task = loop.create_task(
|
||||
dns_update_worker(
|
||||
hbdclass, cfg, async_queue=async_q, log=log, pushmsg=pushmsg, loop=loop
|
||||
)
|
||||
)
|
||||
return task
|
||||
|
||||
+30
-21
@@ -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,17 +481,26 @@ 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(
|
||||
"-d", "--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)
|
||||
|
||||
@@ -508,15 +515,17 @@ def main(argv=None):
|
||||
msgonly = True
|
||||
if args.name:
|
||||
iam = args.name
|
||||
cmdargs += ["-n", iam]
|
||||
if args.daemon:
|
||||
fdaemon = True
|
||||
if args.verbose:
|
||||
verbose = True
|
||||
cmdargs.append("--verbose")
|
||||
if args.debug:
|
||||
config.setdefault("debug", 0)
|
||||
config["debug"] += args.debug
|
||||
cmdargs.append("-" + "x" * args.debug)
|
||||
|
||||
cmdargs += argv
|
||||
if verbose:
|
||||
print("cmdargs for restart are %s" % cmdargs)
|
||||
|
||||
@@ -575,7 +584,6 @@ def main(argv=None):
|
||||
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:
|
||||
@@ -589,5 +597,6 @@ def main(argv=None):
|
||||
if dorestart:
|
||||
restart()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+4
-3
@@ -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
|
||||
@@ -221,7 +221,9 @@ class Host:
|
||||
for d in self.__dict__:
|
||||
if d == "connections":
|
||||
cl = []
|
||||
for c in self.connections:
|
||||
for c in ["IPv4", "IPv6"]:
|
||||
if c not in self.connections:
|
||||
continue
|
||||
# dirty ugly hack: fix conn to host backpointer
|
||||
cld = copy.deepcopy(self.connections[c].__dict__)
|
||||
cld["host"] = cld["host"].name
|
||||
@@ -291,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]
|
||||
|
||||
+28
-14
@@ -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('<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">')
|
||||
res.append("<html>")
|
||||
res.append("<head>")
|
||||
res.append(f"<title>Heartbeat</title>")
|
||||
res.append("<title>Heartbeat</title>")
|
||||
if tcss:
|
||||
res.append(tcss)
|
||||
res.append("</head>")
|
||||
@@ -51,7 +53,11 @@ async def start(
|
||||
res += hbdclass.ubHost.buildhosttable()
|
||||
res += hbdclass.ubHost.buildmsgtable(msgs_getter())
|
||||
res.append(
|
||||
"<p> %s (%s)</p>" % (time.strftime("%H:%M:%S", time.localtime(get_now())), config.get("tz", "CET-1CDT"))
|
||||
"<p> %s (%s)</p>"
|
||||
% (
|
||||
time.strftime("%H:%M:%S", time.localtime(get_now())),
|
||||
config.get("tz", "CET-1CDT"),
|
||||
)
|
||||
)
|
||||
res.append("</body></html>")
|
||||
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):
|
||||
@@ -123,13 +131,6 @@ async def start(
|
||||
out.append(f"update started for {n}: {err if err else 'OK'}")
|
||||
return web.Response(text="\n".join(out))
|
||||
|
||||
async def restart(request):
|
||||
# signal main application to perform restart if needed
|
||||
# not implemented here - return OK
|
||||
if log:
|
||||
log(None, "restart request")
|
||||
return web.Response(text="restart request")
|
||||
|
||||
async def live(request):
|
||||
# render template from hbd/templates/live.html using Jinja2
|
||||
# Resolve templates directory relative to the hbd package
|
||||
@@ -139,6 +140,9 @@ async def start(
|
||||
host = config.get("hb_host", "localhost")
|
||||
extra_scripts = config.get("http_extra_scripts", "")
|
||||
host = request.host.split(":")[0]
|
||||
if config.get("wss_port"):
|
||||
heartbeat_ws_url = f"wss://{host}:{config['wss_port']}/hbd"
|
||||
else:
|
||||
heartbeat_ws_url = f"ws://{host}:{config.get('ws_port', 50005)}/hbd"
|
||||
tmpl = env.get_template("live.html")
|
||||
body = tmpl.render(
|
||||
@@ -147,7 +151,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")
|
||||
@@ -158,6 +164,7 @@ async def start(
|
||||
URL form: /static/<path>
|
||||
"""
|
||||
p = request.match_info.get("path", "")
|
||||
logger.debug("static file requested: %s", p)
|
||||
base = os.path.abspath(os.path.join(os.path.dirname(__file__), "static"))
|
||||
# normalize and prevent directory traversal
|
||||
target = os.path.abspath(os.path.normpath(os.path.join(base, p)))
|
||||
@@ -168,6 +175,14 @@ async def start(
|
||||
logger.info("serving static file: %s", target)
|
||||
return web.FileResponse(path=target)
|
||||
|
||||
async def favicon(request):
|
||||
"""Serve favicon.ico from the package static directory."""
|
||||
base = os.path.abspath(os.path.join(os.path.dirname(__file__), "static/images"))
|
||||
target = os.path.join(base, "favicon.ico")
|
||||
if not os.path.exists(target) or not os.path.isfile(target):
|
||||
return web.Response(status=404, text="Not Found")
|
||||
return web.FileResponse(path=target)
|
||||
|
||||
app = web.Application()
|
||||
app.add_routes(
|
||||
[
|
||||
@@ -178,9 +193,9 @@ async def start(
|
||||
web.get("/d", drop),
|
||||
web.get("/n", register),
|
||||
web.get("/u", update),
|
||||
web.get("/r", restart),
|
||||
web.get("/live", live),
|
||||
web.get("/static/{path:.*}", static),
|
||||
web.get("/favicon.ico", favicon),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -196,4 +211,3 @@ async def start(
|
||||
await asyncio.Future()
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
|
||||
|
||||
+14
-10
@@ -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, email: 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,25 +26,25 @@ def checkoverdue(config: dict, hbdclass, log: callable, email: callable, pushmsg
|
||||
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 != []:
|
||||
if h in config.get("watchhosts", []):
|
||||
email("overdue", "%s overdue" % " and ".join(pmsg))
|
||||
pushmsg("%s %s overdue" % (h, " and ".join(pmsg)))
|
||||
log(h, "%s overdue" % " and ".join(pmsg))
|
||||
msg_to_websockets("host", hbdclass.Host.hosts[h].stateinfo())
|
||||
|
||||
|
||||
async def start(
|
||||
config: dict,
|
||||
hbdclass: callable,
|
||||
log=None,
|
||||
email=None,
|
||||
pushmsg=None,
|
||||
msg_to_websockets=None,
|
||||
):
|
||||
"""start a monitor loop that checks for overdue hosts every minute"""
|
||||
while True:
|
||||
await asyncio.sleep(15) # 15 seconds between checks
|
||||
checkoverdue(config, hbdclass, log, email, pushmsg, msg_to_websockets)
|
||||
checkoverdue(config, hbdclass, log, pushmsg, msg_to_websockets)
|
||||
|
||||
+60
-13
@@ -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"]
|
||||
|
||||
@@ -21,13 +21,21 @@ def setup(cfg: dict):
|
||||
_config = dict(cfg)
|
||||
|
||||
|
||||
def send_email(aemail, smtpserver, sender, subject, body, debug=0):
|
||||
def send_email(toaddrs, smtpserver, sender, subject, body, debug=0):
|
||||
"""Send a plain email via SMTP. Returns True on success."""
|
||||
try:
|
||||
server = smtplib.SMTP(smtpserver)
|
||||
smtpport = _config.get("smtpport", 587)
|
||||
server = smtplib.SMTP(smtpserver, smtpport)
|
||||
if debug > 0:
|
||||
server.set_debuglevel(1)
|
||||
server.sendmail(sender, aemail, body)
|
||||
if smtpport == 587:
|
||||
server.starttls()
|
||||
server.ehlo()
|
||||
smtpuser = _config.get("smtpuser", None)
|
||||
smtppassword = _config.get("smtppassword", None)
|
||||
if smtpuser and smtppassword:
|
||||
server.login(smtpuser, smtppassword)
|
||||
server.sendmail(sender, toaddrs, body)
|
||||
except Exception as e:
|
||||
logger.warning("email send failed: %s", e)
|
||||
try:
|
||||
@@ -48,9 +56,17 @@ def email(subject: str, msg: str, debug: int = 0) -> bool:
|
||||
Uses module-level configuration to supply recipient list, smtp server
|
||||
and sender address.
|
||||
"""
|
||||
toaddrs = _config.get("AEMAIL") or _config.get("aemail") or _config.get("email_to") or []
|
||||
fromemail = _config.get("fromemail") or _config.get("sender") or f"aew.heartbeat@{_config.get('domain','local') }"
|
||||
smtpserver = _config.get("SMTPSERVER") or _config.get("smtpserver") or _config.get("SMTPSERVER", "localhost")
|
||||
toaddrs = _config.get("toemail")
|
||||
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,
|
||||
)
|
||||
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" % (
|
||||
toaddrs[0] if toaddrs else "",
|
||||
@@ -80,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.
|
||||
@@ -104,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.
|
||||
@@ -137,14 +163,36 @@ 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)
|
||||
results["email"] = ok
|
||||
logger.debug("push results: %s", results)
|
||||
return results
|
||||
|
||||
@@ -152,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)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Message encoding/decoding utilities for hbd protocol."""
|
||||
|
||||
from typing import Dict, Any
|
||||
import zlib
|
||||
|
||||
|
||||
+71
-24
@@ -1,10 +1,12 @@
|
||||
"""Server runtime: starts UDP listener, HTTP server and websocket stubs."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import atexit
|
||||
import socket
|
||||
import time
|
||||
import signal
|
||||
import sys
|
||||
import ssl
|
||||
from . import __version__
|
||||
|
||||
from . import udp
|
||||
@@ -21,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}"
|
||||
@@ -42,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")
|
||||
@@ -57,14 +64,14 @@ def cleanup_function(config):
|
||||
|
||||
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)
|
||||
|
||||
@@ -72,9 +79,6 @@ 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
|
||||
@@ -82,11 +86,24 @@ async def _run_async(config):
|
||||
|
||||
notify_mod.setup(config)
|
||||
|
||||
email = notify_mod.email
|
||||
pushmsg = notify_mod.pushmsg_from_config
|
||||
|
||||
sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
|
||||
# Disable IPV6_V6ONLY option to enable dual-stack (listen on IPv4 as well)
|
||||
# This option is system-dependent; on many systems, setting it to False enables
|
||||
# the socket to handle both IPv4 and IPv6 traffic.
|
||||
try:
|
||||
sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, False)
|
||||
except OSError as 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
|
||||
|
||||
# UDP server endpoint (handler wired to handle_datagram with context)
|
||||
bind_addr = ("0.0.0.0", config.get("hb_port", 50003))
|
||||
bind_addr = ("::", config.get("hb_port", 50003))
|
||||
sock.bind(bind_addr)
|
||||
logger.info("Starting UDP server on %s:%s", *bind_addr)
|
||||
|
||||
def udp_handler(msg, addr, transport):
|
||||
@@ -94,7 +111,6 @@ async def _run_async(config):
|
||||
config=config,
|
||||
hbdclass=hbdclass,
|
||||
log=log,
|
||||
email=email,
|
||||
pushmsg=pushmsg,
|
||||
msg_to_websockets=msg_to_websockets,
|
||||
DEBUG=config.get("debug", 0),
|
||||
@@ -104,7 +120,7 @@ async def _run_async(config):
|
||||
|
||||
transport, protocol = await loop.create_datagram_endpoint(
|
||||
lambda: udp.EchoServerProtocol(config=config, handler=udp_handler),
|
||||
local_addr=bind_addr,
|
||||
sock=sock,
|
||||
)
|
||||
|
||||
# HTTP server (asyncio-based via aiohttp)
|
||||
@@ -117,7 +133,6 @@ async def _run_async(config):
|
||||
hbdclass=hbdclass,
|
||||
msgs_getter=lambda: msgs,
|
||||
log=log,
|
||||
email=email,
|
||||
pushmsg=pushmsg,
|
||||
msg_to_websockets=msg_to_websockets,
|
||||
tcss=None,
|
||||
@@ -127,27 +142,54 @@ 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, email=email, 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)
|
||||
|
||||
# Start the websocket servers as a background task
|
||||
if config.get("wss_port", None):
|
||||
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ssl_path = config.get("cert_path", "")
|
||||
wss_pem = ssl_path + config.get("wss_pem", "")
|
||||
wss_key = ssl_path + config.get("wss_key", "")
|
||||
try:
|
||||
ssl_context.load_cert_chain(wss_pem, keyfile=wss_key)
|
||||
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,
|
||||
)
|
||||
else:
|
||||
ssl_context = None
|
||||
|
||||
try:
|
||||
ws_task = asyncio.create_task(
|
||||
ws_mod.start(
|
||||
host=config.get("hbd_host", ""),
|
||||
ws_port=config.get("ws_port", 50005),
|
||||
ws_port=config.get("ws_port", None),
|
||||
wss_port=config.get("wss_port", None),
|
||||
ssl_context=None,
|
||||
get_hosts=lambda: [hbdclass.Host.hosts[h].stateinfo() for h in sorted(hbdclass.Host.hosts)],
|
||||
ssl_context=ssl_context,
|
||||
get_hosts=lambda: [
|
||||
hbdclass.Host.hosts[h].stateinfo()
|
||||
for h in sorted(hbdclass.Host.hosts)
|
||||
],
|
||||
get_msgs=lambda: msgs,
|
||||
verbose=config.get("verbose", False),
|
||||
)
|
||||
@@ -163,7 +205,6 @@ async def _run_async(config):
|
||||
config=config,
|
||||
hbdclass=hbdclass,
|
||||
log=log,
|
||||
email=email,
|
||||
pushmsg=pushmsg,
|
||||
msg_to_websockets=msg_to_websockets,
|
||||
)
|
||||
@@ -199,7 +240,10 @@ async def _run_async(config):
|
||||
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:
|
||||
@@ -207,7 +251,7 @@ async def _run_async(config):
|
||||
|
||||
# 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:
|
||||
@@ -251,7 +295,7 @@ def load_pickled_hosts(config, hbdclass):
|
||||
msgs = pick.load()
|
||||
try:
|
||||
lastfm = pick.load()
|
||||
except:
|
||||
except Exception:
|
||||
lastfm = ["", "", ""]
|
||||
pickf.close()
|
||||
except Exception as e:
|
||||
@@ -271,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).
|
||||
|
||||
@@ -278,10 +323,10 @@ 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"))
|
||||
@@ -313,7 +358,9 @@ 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:
|
||||
|
||||
@@ -185,11 +185,11 @@
|
||||
function WS_Connect() {
|
||||
if ("WebSocket" in window) {
|
||||
//N.B: subprotocol field causes chrome to error 1006
|
||||
var ws_hbd = new WebSocket("{{heartbeat_ws_url}}" /*, "hdb" */);
|
||||
var ws_hbd = new WebSocket("{{heartbeat_ws_url}}", /* "hdb" */ );
|
||||
|
||||
ws_hbd.onopen = function () {
|
||||
// Web Socket is connected, send data using send()
|
||||
console.log("ws connect");
|
||||
console.log("ws connect {{heartbeat_ws_url}}");
|
||||
// Hide modal window if visible
|
||||
var modal = document.getElementById("connectionModal");
|
||||
if (modal) {
|
||||
|
||||
+6
-19
@@ -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.
|
||||
|
||||
@@ -68,7 +70,6 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
||||
- config: dict of configuration
|
||||
- hbdclass: module providing Host/Connection classes
|
||||
- log: callable(loghost, message)
|
||||
- email: callable(subject, message)
|
||||
- pushmsg: callable(message)
|
||||
- msg_to_websockets: callable(typ, data)
|
||||
- DEBUG, verbose
|
||||
@@ -79,7 +80,6 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
||||
cfg = ctx.get("config", {})
|
||||
hbdcls = ctx.get("hbdclass")
|
||||
log = ctx.get("log")
|
||||
email = ctx.get("email")
|
||||
pushmsg = ctx.get("pushmsg")
|
||||
msg_to_websockets = ctx.get("msg_to_websockets")
|
||||
DEBUG = ctx.get("DEBUG", 0)
|
||||
@@ -89,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:
|
||||
@@ -122,8 +123,6 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
||||
if log:
|
||||
log(uname, res)
|
||||
if uname in cfg.get("watchhosts", []):
|
||||
if email:
|
||||
email("address change", "%s %s" % (host.name, res))
|
||||
if pushmsg:
|
||||
pushmsg("%s %s" % (host.name, res))
|
||||
|
||||
@@ -138,16 +137,12 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
||||
log(uname, "booted")
|
||||
if uname in cfg.get("watchhosts", []):
|
||||
m = "%s booted" % (host.name)
|
||||
if email:
|
||||
email("booted", m)
|
||||
if pushmsg:
|
||||
pushmsg(m)
|
||||
if message:
|
||||
if log:
|
||||
log(uname, "msg: %s" % message, service=service)
|
||||
if uname in cfg.get("watchhosts", []):
|
||||
if email:
|
||||
email("msg", message)
|
||||
if pushmsg:
|
||||
pushmsg(message)
|
||||
|
||||
@@ -158,8 +153,6 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
||||
if log:
|
||||
log(uname, m)
|
||||
if uname in cfg.get("watchhosts", []):
|
||||
if email:
|
||||
email("%s back" % conn.afam, uname)
|
||||
if pushmsg:
|
||||
pushmsg("%s %s is back" % (uname, conn.afam))
|
||||
|
||||
@@ -172,8 +165,6 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
||||
if log:
|
||||
log(uname, "%s shutdown" % conn.afam)
|
||||
if uname in cfg.get("watchhosts", []):
|
||||
if email:
|
||||
email("shutdown", "%s %s shutdown" % (uname, conn.afam))
|
||||
if pushmsg:
|
||||
pushmsg("%s %s shutdown" % (uname, conn.afam))
|
||||
conn.newstate(hbdcls.Connection.DOWN, now)
|
||||
@@ -197,8 +188,6 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
||||
while len(host.cmds):
|
||||
op, rmsg = host.cmds[0]
|
||||
if op == "CMD":
|
||||
if email:
|
||||
email("%s cmd exec" % uname, "command '%s' sent" % rmsg)
|
||||
del host.cmds[0]
|
||||
if log:
|
||||
log(uname, "command sent")
|
||||
@@ -229,5 +218,3 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
||||
msg_to_websockets("host", host.stateinfo())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Utility helpers extracted from the original script."""
|
||||
import time
|
||||
|
||||
|
||||
def shortname(name: str) -> str:
|
||||
|
||||
@@ -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,11 +21,8 @@ _verbose = False
|
||||
|
||||
|
||||
async def _handler(websocket, path=None):
|
||||
# Some versions of the websockets library call handler(connection) only;
|
||||
# accept optional path and fall back to websocket.path when missing.
|
||||
global _connections
|
||||
_connections.add(websocket)
|
||||
remote_address = getattr(websocket, "remote_address", None)
|
||||
remote_address = websocket.remote_address
|
||||
if path is None:
|
||||
path = getattr(websocket, "path", None)
|
||||
if _verbose:
|
||||
@@ -48,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:
|
||||
@@ -61,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.
|
||||
@@ -76,36 +85,29 @@ async def start(host: str, ws_port: int, wss_port: Optional[int] = None, ssl_con
|
||||
|
||||
servers = []
|
||||
# plain WebSocket
|
||||
ws_server = websockets.serve(_handler, host, ws_port) #, subprotocols=["hbd"])
|
||||
websockets_logger = logging.getLogger("websockets.server")
|
||||
websockets_logger.setLevel(logging.INFO)
|
||||
websockets_logger.setLevel(logging.DEBUG if verbose else logging.INFO)
|
||||
# regular WebSocket
|
||||
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
|
||||
try:
|
||||
for srv in servers:
|
||||
await srv
|
||||
|
||||
if _verbose:
|
||||
logger.info("WebSocket server 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()
|
||||
except asyncio.CancelledError:
|
||||
logger.info("WebSocket server shutting down...")
|
||||
# Close all active connections
|
||||
for conn in list(_connections):
|
||||
try:
|
||||
await conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
_connections.clear()
|
||||
raise
|
||||
|
||||
|
||||
def broadcast(typ: str, data) -> bool:
|
||||
@@ -114,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})
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: heartbeat
|
||||
Version: 0.1.0
|
||||
Summary: Heartbeat daemon (hbd) — receive heartbeats and act on them
|
||||
Author: heartbeat contributors
|
||||
License: MIT
|
||||
Keywords: heartbeat,monitoring,dns,websocket
|
||||
Requires-Python: >=3.10
|
||||
Description-Content-Type: text/markdown
|
||||
Requires-Dist: websockets>=13.2
|
||||
Requires-Dist: mattermostdriver>=7.3.0
|
||||
Requires-Dist: PyYAML>=6.0
|
||||
Requires-Dist: aiohttp>=3.8
|
||||
Requires-Dist: Jinja2>=3.1.0
|
||||
Provides-Extra: dev
|
||||
Requires-Dist: pytest>=7.0; extra == "dev"
|
||||
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
||||
Requires-Dist: flake8>=5.0; extra == "dev"
|
||||
Requires-Dist: mypy>=1.10; extra == "dev"
|
||||
|
||||
|
||||
|
||||
# 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`).
|
||||
|
||||
---
|
||||
|
||||
## 📌 Features
|
||||
|
||||
- Receive and parse heartbeat datagrams (text or zlib-compressed) ✅
|
||||
- Maintain host state and detect up/down transitions ✅
|
||||
- Queue DNS updates via `nsupdate` and run them in a background thread ✅
|
||||
- WebSocket API for live updates (hosts & messages) ✅
|
||||
- Notification pipeline (email, Pushover, Mattermost, Signal) ✅
|
||||
- Modular codebase suitable for unit testing and CI ✅
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Quickstart
|
||||
|
||||
Prerequisites:
|
||||
- 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
|
||||
```
|
||||
|
||||
Run the daemon (example):
|
||||
|
||||
```bash
|
||||
# run with default config lookup (~/.hb.yaml)
|
||||
PYTHONPATH=. hbd -c .hb.yaml -f -v
|
||||
```
|
||||
|
||||
You can also run it directly via the package entrypoint after installation:
|
||||
|
||||
```bash
|
||||
python -m hbd.cli -c /path/to/config.yaml
|
||||
```
|
||||
|
||||
## 🐞 Debugging in VS Code
|
||||
|
||||
This repository includes a ready-to-use `.vscode/launch.json` with configurations to run or attach the VS Code debugger to `hbd`.
|
||||
|
||||
- Ensure the **Python** extension is installed and select the project `.venv` as the interpreter (bottom-left of VS Code).
|
||||
- Use **F5** and pick one of these configurations from the Run view:
|
||||
- **Python: Run hbd (module)** — runs `hbd.cli` as a module and sets `PYTHONPATH` to the workspace root (recommended).
|
||||
- **Python: Run hbd with debugpy (listen)** — launches `debugpy` and `hbd` together; useful when you want the process to listen for a debugger.
|
||||
- **Python: Attach (localhost:5678)** — attach the debugger to a running process started with `debugpy`.
|
||||
|
||||
To start `hbd` manually and wait for the debugger to attach, run:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=. python -m debugpy --listen 5678 --wait-for-client -m hbd.cli -c .hb.yaml -f -v
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
`hbd` reads YAML configuration (optional). If `PyYAML` is not installed, built-in defaults are used. Example configuration keys (see `hbd/config.py`):
|
||||
|
||||
- `hb_port`: UDP port to listen for heartbeats (default: 50003)
|
||||
- `hbd_port`: internal control port (default: 50004)
|
||||
- `hbd_host`: bind address for HTTP/WSS
|
||||
- `pickfile`: path for persisted state
|
||||
- `logfile`: path to log file
|
||||
- `logfmt`: `text` or `msg`
|
||||
- `pushsrv`: push service (`pushover`|`mattermost`|`all`)
|
||||
- `interval` / `grace`: heartbeat timing configuration
|
||||
- `dyndomains`: list of dyndomains to update via `nsupdate`
|
||||
- `nsupdate_bin`: path to nsupdate binary
|
||||
|
||||
Example `.hb.yaml` (minimal):
|
||||
|
||||
```yaml
|
||||
hbd_host: 0.0.0.0
|
||||
hbd_port: 50004
|
||||
dyndomains:
|
||||
- example.com
|
||||
nsupdate_bin: /usr/bin/nsupdate
|
||||
pushsrv: pushover
|
||||
```
|
||||
|
||||
> Tip: `config.DEFAULTS` in `hbd/config.py` contains the canonical defaults and accepted configuration keys.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Architecture & Modules
|
||||
|
||||
- `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.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
|
||||
- `hbd.utils` — small utility helpers (`shortname`, `dur`, `initlog`)
|
||||
- `hbd.cli` — CLI entrypoint and argument parsing
|
||||
- `hbd.server` — async orchestration to run UDP/HTTP/WSS components
|
||||
|
||||
This modular layout makes the code easier to test and maintain.
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing & Dev
|
||||
|
||||
Tests are implemented using `unittest` and additional tests rely on `pytest` if you prefer. To run tests locally without installing anything beyond the dev requirements:
|
||||
|
||||
```bash
|
||||
# with project root on PYTHONPATH
|
||||
PYTHONPATH=. python -m unittest discover -v
|
||||
# or with pytest if installed
|
||||
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:
|
||||
|
||||
```bash
|
||||
# after installing dev deps
|
||||
tox -e lint
|
||||
tox -e mypy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Running in production
|
||||
|
||||
- Use your system service manager (systemd, launchd, etc.) to run `hbd` in the background.
|
||||
- Ensure `nsupdate` and necessary credentials are available for dynamic DNS updates.
|
||||
- Configure TLS for WSS if you enable secure websockets.
|
||||
|
||||
> Note: The project contains a small example for obtaining DNS-verified certs (certbot with RFC2136) — see earlier commit history or ask me to re-add the example to this README if you want it documented here.
|
||||
|
||||
---
|
||||
|
||||
## 🤝 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.
|
||||
4. Keep changes focused and documented.
|
||||
|
||||
---
|
||||
|
||||
## 📜 License
|
||||
|
||||
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? ✨
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
README.md
|
||||
pyproject.toml
|
||||
hbd/__init__.py
|
||||
hbd/cli.py
|
||||
hbd/config.py
|
||||
hbd/dns.py
|
||||
hbd/hbdclass.py
|
||||
hbd/http.py
|
||||
hbd/monitor.py
|
||||
hbd/notify.py
|
||||
hbd/proto.py
|
||||
hbd/server.py
|
||||
hbd/udp.py
|
||||
hbd/utils.py
|
||||
hbd/ws.py
|
||||
heartbeat.egg-info/PKG-INFO
|
||||
heartbeat.egg-info/SOURCES.txt
|
||||
heartbeat.egg-info/dependency_links.txt
|
||||
heartbeat.egg-info/entry_points.txt
|
||||
heartbeat.egg-info/requires.txt
|
||||
heartbeat.egg-info/top_level.txt
|
||||
tests/test_dns.py
|
||||
tests/test_handle_datagram.py
|
||||
tests/test_proto.py
|
||||
tests/test_udp.py
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
[console_scripts]
|
||||
hbd = hbd.cli:main
|
||||
@@ -1,11 +0,0 @@
|
||||
websockets>=13.2
|
||||
mattermostdriver>=7.3.0
|
||||
PyYAML>=6.0
|
||||
aiohttp>=3.8
|
||||
Jinja2>=3.1.0
|
||||
|
||||
[dev]
|
||||
pytest>=7.0
|
||||
pytest-cov>=4.0
|
||||
flake8>=5.0
|
||||
mypy>=1.10
|
||||
@@ -1 +0,0 @@
|
||||
hbd
|
||||
+16
-7
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hbd"
|
||||
version = "5.0"
|
||||
version = "5.0.5"
|
||||
description = "Heartbeat daemon (hbd) — receive heartbeats and act on them"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = { text = "MIT" }
|
||||
requires-python = ">=3.11"
|
||||
license = "MIT"
|
||||
keywords = ["heartbeat", "monitoring", "dns", "websocket"]
|
||||
authors = [
|
||||
{ name = "heartbeat contributors" }
|
||||
@@ -18,9 +18,9 @@ dependencies = [
|
||||
"websockets>=13.2",
|
||||
"mattermostdriver>=7.3.0",
|
||||
"PyYAML>=6.0",
|
||||
"aiohttp>=3.8",
|
||||
"Jinja2>=3.1.0",
|
||||
"fastapi>=0.95.0",
|
||||
"aiohttp>=3.11",
|
||||
"Jinja2>=3.1.6",
|
||||
"fastapi>=0.128.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -31,7 +31,6 @@ dev = [
|
||||
"mypy>=1.10",
|
||||
"black>=23.0",
|
||||
"isort>=5.0",
|
||||
"re-commit>=3.0",
|
||||
"tox>=4.0",
|
||||
]
|
||||
|
||||
@@ -45,3 +44,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
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
uv version --bump patch
|
||||
VER=$(uv version --short)
|
||||
sed -i "s/__version__ = \"[0-9.]*\"\(.*\)$/__version__ = \"$VER\"\1/" moninbox/const.py
|
||||
sed -i "" "s/__version__ = \"[0-9.]*\"\(.*\)$/__version__ = \"$VER\"\1/" hbd/__init__.py
|
||||
|
||||
# commit pyproject.toml
|
||||
git commit -m "version $VER" pyproject.toml moninbox/const.py
|
||||
git commit -m "version $VER" pyproject.toml hbd/__init__.py
|
||||
git push
|
||||
# tag version
|
||||
git tag -a v$VER -m "Version $VER"
|
||||
|
||||
Regular → Executable
+1
-1
@@ -5,7 +5,7 @@
|
||||
set -e
|
||||
if [ ! -d ~/venvs/hbd ]; then
|
||||
mkdir -p ~/venvs
|
||||
python3 -m venv ~/venvs/hbd
|
||||
python3 -m venv ~/venvs/hbd --system-site-packages
|
||||
fi
|
||||
. ~/venvs/hbd/bin/activate
|
||||
pip install 'git+ssh://git@git.wrede.ca/andreas/heartbeat.git'
|
||||
+29
-6
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import pytest
|
||||
from hbd.proto import dicttos, stodict, oldmtodict
|
||||
|
||||
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user