refactor and rewrite for asyncio

This commit is contained in:
2026-02-06 12:34:59 -05:00
parent 700ea8d6a4
commit 4df700e4ef
54 changed files with 772 additions and 4334 deletions
+3
View File
@@ -6,3 +6,6 @@ __pycache__/
.flake8
.venv/
test/
build/
dist/
*.egg-info/
+6
View File
@@ -11,11 +11,17 @@ watchhosts:
# "haschloss" :
# "cotgate":
# "wentworth":
"y":
notify: +4915123456789
src: "signal"
"winter":
notify: +14168226179
src: "signal"
dyndnshosts: {"haschloss", "wayback", "wertvoll", "weekend", "cotgate", "rvgate", "draper", "eris"}
drophosts: {"unknown", "wookie15", "wort"}
nsupdate_bin: "/usr/local/bin/nsupdate"
pushover_token: "ac7NLX2rPjXFareeDgLpXNoDf4iFmf"
pushover_user: "uDhH33UjQQDYtNzJb1ThRiWb9ingGK"
pushsrv: "pushover"
dyndomains: {"wrede.org"}
-22
View File
@@ -1,22 +0,0 @@
to install on home assistant OS:
- copy hbc and run_hbc to /config/bin on ha
- add these two lines to configuration.yaml, set XXXX to name of the HA instance
shell_command:
run_hbc: /config/bin/run_hbc -n XXXXX -d hbd.wrede.ca
- add these lines to automation.yaml
- id: '1641839711280'
alias: run hbc on boot
description: ''
trigger:
- platform: homeassistant
event: start
condition: []
action:
- service: shell_command.run_hbc
mode: single
-11
View File
@@ -1,11 +0,0 @@
"""hbd package - scaffolding for heartbeat daemon
This package contains the refactored modules for the original monolithic
`hbd` script. The initial implementation contains small scaffolds so you can
start moving functionality into the package.
"""
__all__ = ["main", "__version__"]
__version__ = "0.1"
from .cli import main
-45
View File
@@ -1,45 +0,0 @@
"""Command line interface for hbd package."""
import argparse
from .config import load_config
from .server import run as run_server
PUSHSRVS = ["all", "pushover", "mattermost"]
def build_parser():
parser = argparse.ArgumentParser(
prog="hbd",
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("-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")
return parser
def main(argv=None):
parser = build_parser()
args = parser.parse_args(argv)
config = load_config(args.configfile)
# Apply CLI overrides
if args.foreground:
config["foreground"] = True
if args.verbose:
config["verbose"] = True
if args.pushsrv:
config["pushsrv"] = args.pushsrv
if args.debug:
config.setdefault("debug", 0)
config["debug"] += args.debug
run_server(config)
if __name__ == "__main__":
main()
-54
View File
@@ -1,54 +0,0 @@
"""Configuration loader and defaults for hbd."""
import os
try:
import yaml
except Exception:
yaml = None
DEFAULTS = {
"hb_port": 50003,
"hbd_port": 50004,
"hbd_host": "",
"pickfile": "/tmp/hb.pick",
"logfile": "/var/log/heartbeat.log",
"logfmt": "text",
"pushsrv": "pushover",
"interval": 20,
"grace": 2,
"dyndomains": ["wrede.org"],
"watchhosts": [],
"dyndnshosts": [],
"drophosts": [],
"nsupdate_bin": "/usr/bin/nsupdate",
"foreground": False,
"verbose": False,
"debug": 0,
}
def load_config(path=None):
"""Load configuration from a YAML file and merge with defaults.
If YAML is not available or the file does not exist, defaults are returned.
"""
cfg = DEFAULTS.copy()
if not path:
# default path (~/.hb.yaml)
path = os.path.join(os.path.expanduser("~"), ".hb.yaml")
if os.path.exists(path):
if yaml:
with open(path) as fh:
data = yaml.safe_load(fh) or {}
# only keep known keys
for k, v in data.items():
if k in cfg:
cfg[k] = v
else:
# ignore unknown keys for now
pass
else:
# yaml not installed: do not attempt to parse; user must ensure defaults
pass
return cfg
-91
View File
@@ -1,91 +0,0 @@
"""DNS update helper and thread for heartbeat daemon."""
from __future__ import annotations
import threading
import subprocess
from subprocess import Popen, PIPE, STDOUT
from typing import Optional
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
update add %(fqdn)s %(dnsttl)s AAAA %(newip)s
update delete %(fqdn)s TXT
update add %(fqdn)s %(dnsttl)s TXT "Created: %(ts)s"
send
answer
""" % D
)
else:
nsup = (
"""update delete %(fqdn)s A
update add %(fqdn)s %(dnsttl)s A %(newip)s
update delete %(fqdn)s TXT
update add %(fqdn)s %(dnsttl)s TXT "Created: %(ts)s"
send
answer
""" % 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]:
"""Perform DNS update via nsupdate command.
Returns None on success, else returns combined stdout/stderr as a string.
"""
nsup = create_nsupdate_payload(hostname, newip, dyndomain)
cmd = [nsupdate_bin, "-k", rndc_key, "-v"]
try:
p = Popen(cmd, shell=False, bufsize=0, stdin=PIPE, stdout=PIPE, stderr=STDOUT)
except OSError as e:
return f"nsupdate: execution failed: {e}"
except Exception as e:
return f"nsupdate: some error occured: {e}"
(output, err) = p.communicate(nsup.encode())
out = output.decode() if output else ""
if out.find("status: NOERROR") >= 0:
return None
return out
def dnsupdatethread(hbdclass, cfg: dict, log: Optional[callable] = None, email: Optional[callable] = None):
"""Thread target: process dns update queue from hbdclass.Host.dnsQ.
hbdclass: module with Host class that exposes dnsQ queue
cfg: configuration mapping with 'dyndomains' and 'nsupdate_bin'
log: callable(host, message)
email: callable(subject, message)
"""
while True:
name, addr = hbdclass.Host.dnsQ.get()
m = f"changed address to {addr}"
for dyndomain in cfg.get("dyndomains", []):
err = nsupdate(name, addr, dyndomain, nsupdate_bin=cfg.get("nsupdate_bin", "/usr/local/bin/nsupdate"))
if err:
m += f", DNS update failed: {err}"
if email:
try:
email("error: nsupdate failed", f"{name}.dy.{dyndomain}: {m}")
except Exception:
pass
else:
m += ", DNS updated."
hbdclass.Host.dnsQ.task_done()
if log:
try:
log(name, m)
except Exception:
pass
def start_dns_thread(hbdclass, cfg: dict, log: Optional[callable] = None, email: Optional[callable] = None) -> threading.Thread:
t = threading.Thread(target=dnsupdatethread, args=(hbdclass, cfg, log, email))
t.daemon = True
t.start()
return t
-235
View File
@@ -1,235 +0,0 @@
"""HTTP server and handler scaffolds (thin wrappers around http.server)."""
from http import server
import json
import time
import urllib.parse
from urllib3 import request
from fastapi.templating import Jinja2Templates
class HttpServer(server.ThreadingHTTPServer):
allow_reuse_address = True
def threaded(self):
pass
def make_handler_class(
config,
hbdclass,
msgs_getter,
log=None,
email=None,
pushmsg=None,
msg_to_websockets=None,
tcss=None,
DEBUG=0,
verbose=False,
get_now=None,
VER="",
):
"""Return a BaseHTTPRequestHandler subclass bound to runtime objects.
`msgs_getter` should be a callable that returns a list-like of messages.
"""
templates = Jinja2Templates(directory="templates")
get_now = get_now or (lambda: time.time())
class CustomHandler(server.BaseHTTPRequestHandler):
server_version = f"HeartbeatHTTP/{VER}"
def version_string(self):
return self.server_version
def handle(self):
try:
return server.BaseHTTPRequestHandler.handle(self)
except Exception as e:
self.log_error("Request went away: %r", e)
self.close_connection = 1
return
def do_HEAD(self):
self.setheaders(200)
def setheaders(self, code, headerdict={}):
self.send_response(code)
self.send_header(
"Last-Modified",
time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(get_now())),
)
for h in headerdict:
self.send_header(h, headerdict[h])
self.end_headers()
def buildhead(self, title="Heartbeat", refresh=None, extras=None):
res = []
res.append('<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">')
res.append("<html>")
res.append("<head>")
res.append("<title>%s</title>" % (title))
if refresh:
res.append("<meta http-equiv = Refresh content = %d>\n" % refresh)
if extras:
res.append(extras)
res.append("</head>")
res.append('<body BGCOLOR = "#FFFFFF" LINK = "#008000" VLINK = "#008000">')
return res
def buildpage(self):
res = self.buildhead(refresh=60, extras=tcss)
res.append("<H2>Heartbeat status %s</h2>" % VER)
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"))
)
res.append("</body></html>")
return res
def builderror(self, code, cause, lcause):
res = []
res.append('<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">')
res.append("<html><head>")
res.append("<title>%s %s</title>" % (code, cause))
res.append("</head><body>")
res.append("<h1>%s</h1>" % (cause))
res.append("<p>%s</p>" % lcause)
res.append("<hr>")
res.append(
"<address>hbd (Unix) Server at %s:%s</address>" % (config.get("hbd_host"), config.get("hbd_port"))
)
res.append("</body></html>")
return code, res
def do_GET(self):
xsig = 0
rqAcceptEncoding = self.headers.get("Accept-encoding", {})
headerdict = {"Content-Type": "text/html; charset = ISO-8859-1"}
qr = urllib.parse.urlparse(self.path)
qa = urllib.parse.parse_qs(qr.query)
if qr.path == "/":
res = self.buildpage()
elif qr.path == "/c": # command on host /c?h=melschserver&c=sudo%20ls
uname = qa.get("h", [None])[0]
ucmd = qa.get("c", [None])[0]
if not ucmd or not uname:
code, res = self.builderror(400, "Argument error", "need h= and c= arguments")
elif uname not in hbdclass.Host.hosts:
code, res = self.builderror(400, "Data error", "h=%s not found" % uname)
else:
hbdclass.Host.hosts[uname].cmds.append(("CMD", {"cmd": urllib.parse.unquote(ucmd)}))
res = self.buildhead()
res.append("cmd %s queued for host %s" % (uname, ucmd))
elif qr.path == "/d": # drop host /d?h=melschserver
uname = qa.get("h", [None])[0]
if not uname:
code, res = self.builderror(400, "Argument error", "need h= argument")
if uname not in hbdclass.Host.hosts:
code, res = self.builderror(400, "Data error", "h=%s not found" % uname)
else:
if log:
log(uname, "dropped")
del hbdclass.Host.hosts[uname]
res = self.buildhead()
res.append("Done")
elif qr.path == "/n": # register name
uname = qa.get("h", [None])[0]
if not uname:
code, res = self.builderror(400, "Argument error", "need h= argument")
if uname not in hbdclass.Host.hosts:
code, res = self.builderror(400, "Data error", "h=%s not found" % uname)
else:
ll = hbdclass.Host.hosts[uname].registerDns()
res = self.buildhead()
res.append(ll)
if log:
log(uname, ll)
elif qr.path == "/u": # update
uname = urllib.parse.unquote(qa.get("h", [None])[0])
ucode = qa.get("c", [None])[0]
if not ucode or not uname:
code, res = self.builderror(400, "Argument error", "need h= and c= arguments")
elif uname != "All" and uname not in hbdclass.Host.hosts:
code, res = self.builderror(400, "Data error", "h=%s not found" % uname)
else:
res = self.buildhead()
if uname != "All":
names = [uname]
else:
names = []
for n in hbdclass.Host.hosts:
if hbdclass.Host.hosts[n].cver >= 2: # earliest version that supports update
names.append(n)
for n in names:
err = None
try:
from hbd import proto
# read code from a file name, fallback to sending ucode as data
err = None
# attempt to send update command to host
r = {"csum": None, "code": ucode}
hbdclass.Host.hosts[n].cmds.append(("UPD", r))
except Exception as e:
err = str(e)
res.append("update started for %s: %s<br>" % (n, err if err else "OK"))
res.append("Done")
elif qr.path == "/api/0/hosts": # api access to host table
headerdict = {"Content-Type": "application/json; charset=utf-8"}
lst = []
for h in hbdclass.Host.hosts:
lst.append(hbdclass.Host.hosts[h].jsons())
res = ["[" + ",".join(lst) + "]"]
elif qr.path == "/api/0/messages": # api access to host table
headerdict = {"Content-Type": "application/json; charset=utf-8"}
lst = msgs_getter()[-30:]
res = [json.dumps(lst)]
elif qr.path == "/r": # restart
res = self.buildhead()
res.append("restart request")
xsig = 1 # signal.SIGHUP will be handled by application
if log:
log(None, "restart request")
elif qr.path == "/live": # show
heartbeat_ws_url = f"wss://{host}:50006/hbd"
res = templates.TemplateResponse(
"heartbeat.html",
{
"title": "Heartbeat",
"header": "Heartbeat",
"request": request,
"heartbeat_ws_url": heartbeat_ws_url,
"extra_scripts": extra_scripts,
},
)
else:
code, res = self.builderror(404, "Not Found", "requested URL was not found on this server.")
if "deflate" in rqAcceptEncoding:
headerdict["Content-Encoding"] = "deflate"
towrite = __import__("zlib").compress("\n".join(res).encode(), 6)
else:
towrite = "\n".join(res)
headerdict["Content-Length"] = len(towrite)
headerdict["Cache-Control"] = "private, must-revalidate, max-age=0"
headerdict["Expires"] = "Thu, 01 Jan 1970 00:00:00 GMT"
self.setheaders(200 if 'res' in locals() else code, headerdict)
self.wfile.write(towrite if isinstance(towrite, bytes) else towrite.encode())
if xsig:
# inform application via setting a flag on the server instance
try:
self.server.xsig = xsig
except Exception:
pass
return CustomHandler
-163
View File
@@ -1,163 +0,0 @@
"""Notification helpers: email, pushover, mattermost, signal and dispatcher."""
from typing import Optional
import http.client
import urllib.parse
import subprocess
import smtplib
import time
import traceback
DEFAULT_PUSHPROVIDERS = ["all", "pushover", "mattermost", "signal"]
# module-level configuration set via setup()
_config = {}
def setup(cfg: dict):
"""Initialize notifier defaults from a configuration dict."""
global _config
_config = dict(cfg)
def send_email(aemail, smtpserver, sender, subject, body, debug=0):
"""Send a plain email via SMTP. Returns True on success."""
try:
server = smtplib.SMTP(smtpserver)
if debug > 0:
server.set_debuglevel(1)
server.sendmail(sender, aemail, body)
except Exception as e:
if debug:
print("email send failed:", e)
try:
server.quit()
except Exception:
pass
return False
try:
server.quit()
except Exception:
pass
return True
def email(subject: str, msg: str, debug: int = 0) -> bool:
"""Convenience wrapper exposed to the rest of the application.
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")
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 "",
fromemail,
subject,
date,
msg,
)
return send_email(toaddrs, smtpserver, fromemail, subject, body, debug=debug)
def pushover(token: str, user: str, msg: str, debug: int = 0) -> bool:
"""Send message via Pushover API."""
conn = http.client.HTTPSConnection("api.pushover.net:443")
try:
conn.request(
"POST",
"/1/messages.json",
urllib.parse.urlencode({"token": token, "user": user, "message": msg}),
{"Content-type": "application/x-www-form-urlencoded"},
)
r = conn.getresponse()
if debug:
print("pushover response:", r.status, r.reason)
return r.status == 200
except Exception as e:
if debug:
print("pushover error:", e)
return False
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.
If the import fails it returns False.
"""
try:
from mattermostdriver import Driver
except Exception:
return False
ses = {"url": host, "scheme": "http", "basepath": "/api/v4", "port": 8065}
mm = Driver(ses)
payload = {"text": msg, "channel": channel, "username": username}
if icon:
payload["icon_url"] = icon
try:
rc = mm.webhooks.call_webhook(token, payload)
if debug:
print("mattermost rc:", rc)
return bool(rc is None or rc == "")
except Exception as e:
if debug:
print("mattermost error:", e)
return False
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.
"""
CLI = [signal_cli_bin, "-u", user, "send", "-m", msg, recipient]
if debug:
print("signal cli: ", CLI)
try:
res = subprocess.run(CLI, capture_output=True)
if res.returncode != 0:
if debug:
print("signal failed:", res.stderr.decode())
return False
if debug:
print("signal sent:", res.stdout.decode())
return True
except Exception as e:
if debug:
print("signal exception:", e)
return False
def pushmsg(cfg: dict, msg: str, debug: int = 0):
"""Dispatch push notifications according to `cfg['pushsrv']`.
cfg is expected to contain keys for different services when needed, e.g.
- cfg['pushsrv'] : one of 'all', 'pushover', 'mattermost', 'signal'
- cfg['pushover_token'], cfg['pushover_user']
- cfg['matter_host'], cfg['matter_token'], cfg['matter_channel']
- cfg['signal_cli'], cfg['signal_user'], cfg['signal_recipient']
Returns a dict of results per provider.
"""
results = {}
p = cfg.get("pushsrv", "pushover")
if p in ("all", "pushover"):
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)
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)
results["signal"] = ok
if debug:
print("push results:", results)
return results
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)
-81
View File
@@ -1,81 +0,0 @@
"""Message encoding/decoding utilities for hbd protocol."""
from typing import Dict, Any
import zlib
def dicttos(ID: str, d: Dict[str, Any], compress: bool = False):
"""Serialize a dict to protocol message bytes.
If compress is True, the payload is zlib-compressed and the message is
prefixed with `!ID:` as the original script did. Otherwise the format is
`ID:key=value;...` (bytes).
"""
s = []
for k in d:
v = d[k]
if isinstance(v, float):
s.append(f"{k}={v:0.5f}")
else:
s.append(f"{k}={v}")
pk = ";".join(s)
if compress:
zpk = zlib.compress(pk.encode(), 6)
hdr = ("!" + ID + ":").encode()
return hdr + zpk
else:
return (ID + ":" + pk).encode()
def stodict(msg: bytes):
"""Deserialize a protocol message into a dict.
Mirrors original behaviour: detects compressed messages starting with
'!' and decodes accordingly. Returns a dict with key 'ID' set to the
message ID and the parsed key/value pairs.
"""
d = {}
if len(msg) > 0 and chr(msg[0]) == "!":
# message is: b'!ID:' + compressed_payload
# original code used msg[1:4].decode() for ID (3 bytes including colon)
try:
pk = zlib.decompress(msg[5:]).decode()
except Exception:
# malformed compressed payload
return {}
d["ID"] = msg[1:4].decode()
else:
try:
r0 = msg.split(b":", 1)
pk = r0[1].decode()
d["ID"] = r0[0].decode()
except Exception:
return {}
if not pk:
return d
parts = pk.split(";")
for v in parts:
if not v:
continue
vr = v.split("=", 1)
k = vr[0].strip()
if len(vr) == 1:
d[k] = None
else:
val = vr[1].strip()
if val and val[0].isdigit():
try:
val_e = eval(val)
except Exception:
val_e = val
d[k] = val_e
else:
d[k] = val
return d
def oldmtodict(msg: bytes):
"""Compatibility wrapper for old-style messages (no ID prefix).
The original implementation prefixed with 'HTB:' and called stodict.
"""
return stodict(b"HTB:" + msg)
-128
View File
@@ -1,128 +0,0 @@
"""Server runtime: starts UDP listener, HTTP server and websocket stubs."""
import asyncio
import logging
from . import udp
logger = logging.getLogger(__name__)
async def _run_async(config):
loop = asyncio.get_running_loop()
# shared runtime collections and helpers
msgs = []
# prepare runtime dependencies
import threading
import time
import hbdclass
from . import http as http_mod
from . import ws as ws_mod
from . import dns as dns_mod
from . import notify as notify_mod
notify_mod.setup(config)
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}"
msgs.append(s)
logger.info(s)
email = notify_mod.email
pushmsg = notify_mod.pushmsg_from_config
msg_to_websockets = ws_mod.broadcast
# UDP server endpoint (handler wired to handle_datagram with context)
bind_addr = ("0.0.0.0", config.get("hb_port", 50003))
logger.info("Starting UDP server on %s:%s", *bind_addr)
def udp_handler(msg, addr, transport):
ctx = dict(
config=config,
hbdclass=hbdclass,
log=log,
email=email,
pushmsg=pushmsg,
msg_to_websockets=msg_to_websockets,
msgs=msgs,
DEBUG=config.get("debug", 0),
verbose=config.get("verbose", False),
)
udp.handle_datagram(msg, addr, transport, ctx)
transport, protocol = await loop.create_datagram_endpoint(
lambda: udp.EchoServerProtocol(config=config, handler=udp_handler),
local_addr=bind_addr,
)
# HTTP server (runs in its own thread)
try:
handler_cls = http_mod.make_handler_class(
config=config,
hbdclass=hbdclass,
msgs_getter=lambda: msgs,
log=log,
email=email,
pushmsg=pushmsg,
msg_to_websockets=msg_to_websockets,
tcss=None,
DEBUG=config.get("debug", 0),
verbose=config.get("verbose", False),
get_now=lambda: time.time(),
VER="",
)
serv = http_mod.HttpServer((config.get("hbd_host", ""), config.get("hbd_port", 50004)), handler_cls)
http_thread = threading.Thread(target=serv.serve_forever, daemon=True)
http_thread.start()
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 thread
dns_mod.start_dns_thread(hbdclass, config, log=log, email=email)
logger.info("dns update thread started")
# Start the websocket servers as a background task
try:
ws_task = asyncio.create_task(
ws_mod.start(
host=config.get("hbd_host", ""),
ws_port=config.get("ws_port", 50005),
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)],
get_msgs=lambda: msgs,
verbose=config.get("verbose", False),
)
)
logger.info("WebSocket task started")
except Exception as e:
logger.exception("websocket server failed to start: %s", e)
try:
# run forever
await asyncio.Future()
finally:
transport.close()
try:
serv.shutdown()
except Exception:
pass
try:
ws_task.cancel()
except Exception:
pass
def run(config):
"""Start the hbd service (blocking).
This is a thin wrapper around asyncio.run to host the async services.
"""
logging.basicConfig(level=logging.DEBUG if config.get("debug", 0) > 0 else logging.INFO)
try:
asyncio.run(_run_async(config))
except KeyboardInterrupt:
logger.info("Shutting down (KeyboardInterrupt)")
-235
View File
@@ -1,235 +0,0 @@
"""UDP listener and datagram processing."""
import asyncio
from compression import zlib
import logging
logger = logging.getLogger(__name__)
from .proto import stodict, oldmtodict
from hbd.utils import dur
class EchoServerProtocol(asyncio.DatagramProtocol):
def __init__(self, config=None, handler=None):
super().__init__()
self.config = config or {}
self.handler = handler
def connection_made(self, transport):
self.transport = transport
logger.info("UDP Server listening...")
def datagram_received(self, data, addr):
logger.debug("Received from %s", addr)
try:
msg = parse_message(data)
if self.handler:
# handler can be a callable provided by the application
# pass the transport so handlers can send replies (ACKs/commands)
self.handler(msg, addr, self.transport)
except Exception:
logger.exception("Error while processing datagram from %s", addr)
def parse_message(data: bytes):
"""Parse a raw datagram into a message dict.
Uses the protocol decoding helpers and falls back to old format when
decoding returns an empty dict (compat with older clients).
"""
msg = stodict(data)
if not msg:
# fallback to old format
msg = oldmtodict(data)
return msg
def dicttos(ID, d, compress=False):
s = []
for k in d:
if isinstance(d[k], float):
s.append("%s=%0.5f" % (k, d[k]))
else:
s.append("%s=%s" % (k, d[k]))
pk = ";".join(s)
if compress:
zpk = zlib.compress(pk.encode(), 6)
ID = "!" + ID + ":"
opk = ID.encode() + zpk
else:
zpk = pk
opk = ID + ":" + zpk
return opk
def handle_datagram(msg: dict, addr, transport, ctx: dict):
"""Handle a parsed datagram message.
ctx is a dictionary with runtime dependencies:
- 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)
- msgs: list for storing message strings
- DEBUG, verbose
"""
if not msg:
return
now = __import__("time").time()
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")
msgs = ctx.get("msgs")
DEBUG = ctx.get("DEBUG", 0)
verbose = ctx.get("verbose", False)
# normalize addr (ip, port)
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:
host = hbdcls.Host(uname)
host.dyn = uname in cfg.get("dyndnshosts", [])
if verbose:
print(("XX: New host, num now %s" % (len(hbdcls.Host.hosts))))
newh = True
else:
host = hbdcls.Host.hosts[uname]
newh = False
cid = msg.get("id", 0)
try:
rtt = float(msg.get("rtt", None))
except Exception:
rtt = None
if msg.get("ID") == "HTB":
host.doesack = msg.get("acks", -1)
host.setcver(msg.get("ver", 0))
try:
conn, res = host.conndata(cid, ip, rtt, now)
except Exception as e:
if DEBUG > 0:
print("conndata failed: %s" % e)
return
if res:
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))
interval = int(msg.get("interval", 0) or 0)
shutdown = msg.get("shutdown", 0)
service = msg.get("service", "unknown")
message = msg.get("msg", None)
boot = msg.get("boot", 0)
if boot:
if log:
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)
if conn.getstate() != hbdcls.Connection.UP:
lasts = conn.state
d = conn.newstate(hbdcls.Connection.UP, now)
m = "%s back after being %s for %s" % (conn.afam, lasts, dur(d))
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))
if boot or newh:
host.upcount = host.doesack
else:
host.upcount += 1
if shutdown:
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)
if interval > 0:
host.interval = interval
# send ACK back
rmsg = {"time": __import__("time").time()}
if host.cver < 1:
opkt = b"ACK"
else:
opkt = dicttos("ACK", rmsg, host.cver > 1)
try:
transport.sendto(opkt, addr)
except Exception as e:
if DEBUG > 0:
print(("cannot send ack: %s" % e))
# send any commands we have queued
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")
if host.cver < 1:
rmsg = rmsg["cmd"]
elif op == "UPD":
del host.cmds[0]
if log:
log(uname, "update initiated")
if host.cver < 1:
if log:
log(uname, " ver 0 does not support UPD")
continue
if host.cver < 1:
opkt = rmsg if isinstance(rmsg, (bytes, str)) else str(rmsg)
if isinstance(opkt, str):
opkt = opkt.encode()
else:
opkt = dicttos(op, rmsg, True)
try:
transport.sendto(opkt, addr)
except Exception as e:
if DEBUG > 0:
print(("cannot send cmd/update: %s" % e))
if msg_to_websockets:
try:
msg_to_websockets("host", host.stateinfo())
except Exception:
pass
-36
View File
@@ -1,36 +0,0 @@
"""Utility helpers extracted from the original script."""
import time
def shortname(name: str) -> str:
return name.split(".")[0]
def dur(sec: int) -> str:
sec = int(sec)
h = int(sec / 3600)
m = int((sec - h * 3600) / 60)
s = int((sec - h * 3600) % 60)
if h > 0:
return "%d:%02d:%02d" % (h, m, s)
if m > 0:
return "%d:%02d" % (m, s)
return "0:%02d" % s
def initlog(logfile: str):
"""Open logfile for appending; fall back to creating it or returning stderr.
This mirrors the original behaviour from the monolithic script.
"""
try:
return open(logfile, "a+")
except Exception:
pass
try:
return open(logfile, "w")
except Exception as e:
import sys
print(f"cannot open logfile {logfile}, using STDERR: {e}")
return sys.stderr
-125
View File
@@ -1,125 +0,0 @@
"""WebSocket server and broadcast helpers for hbd.
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
from typing import Callable, Iterable, Optional
import websockets
logger = logging.getLogger(__name__)
_connections = set()
_loop: Optional[asyncio.AbstractEventLoop] = None
_get_hosts: Optional[Callable[[], Iterable]] = None
_get_msgs: Optional[Callable[[], Iterable]] = None
_verbose = False
async def _handler(websocket, path):
global _connections
_connections.add(websocket)
remote_address = websocket.remote_address
if _verbose:
logger.info("DBG ws_serve: %s: %s", remote_address, path)
try:
# send initial hosts
if _get_hosts:
for h in _get_hosts():
jmsg = json.dumps({"type": "host", "data": h})
await websocket.send(jmsg)
# send recent messages
if _get_msgs:
for m in list(_get_msgs())[-100:]:
jmsg = json.dumps({"type": "message", "data": m})
await websocket.send(jmsg)
# keep connection open until client disconnects
async for _ in websocket:
# we don't expect meaningful incoming messages besides the initial
# client 'hello' that some clients send; ignore for now
if _verbose:
logger.debug("received ws data: %s", _)
except (websockets.exceptions.ConnectionClosedOK, websockets.exceptions.ConnectionClosedError) as e:
if _verbose:
logger.info("ws closed: %r", e)
except Exception as e:
logger.exception("ws handler exception: %s", e)
finally:
try:
_connections.remove(websocket)
except KeyError:
pass
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):
"""Start WebSocket servers and block until cancelled.
This is intended to be awaited inside the main asyncio event loop.
If `wss_port` and `ssl_context` are provided, a WSS server will also be
started.
"""
global _loop, _get_hosts, _get_msgs, _verbose
_loop = asyncio.get_running_loop()
_get_hosts = get_hosts
_get_msgs = get_msgs
_verbose = verbose
servers = []
# plain 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"])
servers.append(wss_server)
# await starting of all servers
for srv in servers:
await srv
if _verbose:
logger.info("WebSocket server started on port %s (wss %s)", ws_port, wss_port)
# block forever (until loop is stopped or cancelled)
await asyncio.Future()
def broadcast(typ: str, data) -> bool:
"""Thread-safe broadcast helper.
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})
to_close = []
for ws in list(_connections):
if ws.closed:
to_close.append(ws)
continue
try:
asyncio.run_coroutine_threadsafe(ws.send(jmsg), _loop)
except Exception:
to_close.append(ws)
logger.debug("ws.send exception: closed")
for ws in to_close:
try:
asyncio.run_coroutine_threadsafe(ws.wait_closed(), _loop)
except Exception:
pass
if ws in _connections:
_connections.remove(ws)
return True
def connection_count() -> int:
return len(_connections)
-7
View File
@@ -1,7 +0,0 @@
#!/bin/sh
# excute on remote machine
# forwared 2 ports to wig: 5903 to screen shareing and 5922 to ssh
HOST=192.168.10.10
/usr/bin/ssh -f -N -C -R $HOST:5903:127.0.0.1:5900 -R $HOST:5922:127.0.0.1:22 home.wrede.ca
Binary file not shown.
BIN
View File
Binary file not shown.
-8
View File
@@ -1,8 +0,0 @@
#!/bin/sh
# $Id: hbd.sh,v 1.1 2010/04/02 11:09:05 andreas Exp $
while true; do
/home/andreas/bin/hbd -f > /tmp/hbd.$$.log 2>&1
cat /tmp/hbd.$$.log | mail -s "hbd died" andreas@wrede.ca
sleep 10
done
+1 -1
View File
@@ -6,6 +6,6 @@ start moving functionality into the package.
"""
__all__ = ["main", "__version__"]
__version__ = "0.1"
__version__ = "5.0"
from .cli import main
+5 -3
View File
@@ -1,4 +1,5 @@
"""Configuration loader and defaults for hbd."""
import logging
import os
try:
@@ -14,6 +15,8 @@ DEFAULTS = {
"logfile": "/var/log/heartbeat.log",
"logfmt": "text",
"pushsrv": "pushover",
"pushover_token": "",
"pushover_user": "",
"interval": 20,
"grace": 2,
"dyndomains": ["wrede.org"],
@@ -40,14 +43,13 @@ def load_config(path=None):
if os.path.exists(path):
if yaml:
with open(path) as fh:
data = yaml.safe_load(fh) or {}
data = yaml.safe_load(fh)
# only keep known keys
for k, v in data.items():
if k in cfg:
cfg[k] = v
else:
# ignore unknown keys for now
pass
logging.warning("unknown config key %s in %s", k, path)
else:
# yaml not installed: do not attempt to parse; user must ensure defaults
pass
+98 -18
View File
@@ -1,9 +1,9 @@
"""DNS update helper and thread for heartbeat daemon."""
"""DNS update helper and pure asyncio worker for heartbeat daemon."""
from __future__ import annotations
import threading
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:
@@ -54,38 +54,118 @@ def nsupdate(hostname: str, newip: str, dyndomain: str, nsupdate_bin: str = "/us
return out
def dnsupdatethread(hbdclass, cfg: dict, log: Optional[callable] = None, email: Optional[callable] = None):
"""Thread target: process dns update queue from hbdclass.Host.dnsQ.
async def dns_update_worker(hbdclass, cfg: dict, async_queue=None, log: Optional[callable] = None, email: Optional[callable] = None, loop: Optional[asyncio.AbstractEventLoop] = None):
"""Pure async DNS worker that processes updates from asyncio.Queue.
hbdclass: module with Host class that exposes dnsQ queue
cfg: configuration mapping with 'dyndomains' and 'nsupdate_bin'
log: callable(host, message)
email: callable(subject, message)
Exits when it receives a None sentinel.
"""
if loop is None:
loop = asyncio.get_running_loop()
dnsq = async_queue
if not dnsq:
if log:
try:
await loop.run_in_executor(None, log, None, "dns_update_worker: no queue available")
except Exception:
pass
return
while True:
name, addr = hbdclass.Host.dnsQ.get()
try:
item = await dnsq.get()
except Exception as e:
if log:
try:
await loop.run_in_executor(None, log, None, f"dns_update_worker: error getting item: {e}")
except Exception:
pass
break
if item is None:
break
try:
name, addr = item
except Exception:
try:
dnsq.task_done()
except Exception:
pass
continue
m = f"changed address to {addr}"
for dyndomain in cfg.get("dyndomains", []):
err = nsupdate(name, addr, dyndomain, nsupdate_bin=cfg.get("nsupdate_bin", "/usr/local/bin/nsupdate"))
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:
try:
email("error: nsupdate failed", f"{name}.dy.{dyndomain}: {m}")
await loop.run_in_executor(None, email, "error: nsupdate failed", f"{name}.dy.{dyndomain}: {m}")
except Exception:
pass
else:
m += ", DNS updated."
hbdclass.Host.dnsQ.task_done()
try:
dnsq.task_done()
except Exception:
pass
if log:
try:
log(name, m)
await loop.run_in_executor(None, log, name, m)
except Exception:
pass
if log:
try:
await loop.run_in_executor(None, log, None, "dns_update_worker exiting")
except Exception:
pass
def start_dns_thread(hbdclass, cfg: dict, log: Optional[callable] = None, email: Optional[callable] = None) -> threading.Thread:
t = threading.Thread(target=dnsupdatethread, args=(hbdclass, cfg, log, email))
t.daemon = True
t.start()
return t
def start_dns_worker(hbdclass, cfg: dict, log: Optional[callable] = None, email: 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
so legacy synchronous put() calls from UDP handlers still work.
"""
if loop is None:
loop = asyncio.get_event_loop()
# Create asyncio.Queue and wrap in a bridge for thread-safe puts
async_q = asyncio.Queue()
class _QueueBridge:
"""Thread-safe wrapper around asyncio.Queue for synchronous callers."""
def __init__(self, loop, aq):
self._loop = loop
self._aq = aq
def put(self, item):
"""Thread-safe put that schedules onto event loop."""
try:
# Try to detect if we're in the event loop thread
asyncio.get_running_loop()
# We're in event loop context, use put_nowait directly
self._aq.put_nowait(item)
except RuntimeError:
# We're in a different thread, schedule safely
try:
self._loop.call_soon_threadsafe(self._aq.put_nowait, item)
except Exception:
pass
def task_done(self):
"""Delegate task_done to asyncio.Queue."""
try:
self._aq.task_done()
except Exception:
pass
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))
return task
View File
+124 -165
View File
@@ -1,20 +1,23 @@
"""HTTP server and handler scaffolds (thin wrappers around http.server)."""
from http import server
"""HTTP server implementation using aiohttp and jinja2."""
import asyncio
import json
import time
import urllib.parse
from urllib3 import request
import os
import logging
from aiohttp import web
from fastapi.templating import Jinja2Templates
import jinja2
class HttpServer(server.ThreadingHTTPServer):
allow_reuse_address = True
logger = logging.getLogger(__name__)
def threaded(self):
pass
def _render_template(html_str: str, **context) -> str:
tmpl = jinja2.Template(html_str)
return tmpl.render(**context)
def make_handler_class(
async def start(
host: str,
port: int,
config,
hbdclass,
msgs_getter,
@@ -28,209 +31,165 @@ def make_handler_class(
get_now=None,
VER="",
):
"""Return a BaseHTTPRequestHandler subclass bound to runtime objects.
"""Start an aiohttp web server and block until cancelled.
`msgs_getter` should be a callable that returns a list-like of messages.
This function is intended to be awaited inside the main asyncio event loop.
"""
templates = Jinja2Templates(directory="templates")
get_now = get_now or (lambda: time.time())
class CustomHandler(server.BaseHTTPRequestHandler):
server_version = f"HeartbeatHTTP/{VER}"
def version_string(self):
return self.server_version
def handle(self):
try:
return server.BaseHTTPRequestHandler.handle(self)
except Exception as e:
self.log_error("Request went away: %r", e)
self.close_connection = 1
return
def do_HEAD(self):
self.setheaders(200)
def setheaders(self, code, headerdict={}):
self.send_response(code)
self.send_header(
"Last-Modified",
time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(get_now())),
)
for h in headerdict:
self.send_header(h, headerdict[h])
self.end_headers()
def buildhead(self, title="Heartbeat", refresh=None, extras=None):
async def index(request):
res = []
res.append('<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">')
res.append("<html>")
res.append("<head>")
res.append("<title>%s</title>" % (title))
if refresh:
res.append("<meta http-equiv = Refresh content = %d>\n" % refresh)
if extras:
res.append(extras)
res.append(f"<title>Heartbeat</title>")
if tcss:
res.append(tcss)
res.append("</head>")
res.append('<body BGCOLOR = "#FFFFFF" LINK = "#008000" VLINK = "#008000">')
return res
def buildpage(self):
res = self.buildhead(refresh=60, extras=tcss)
res.append("<H2>Heartbeat status %s</h2>" % VER)
res.append(f"<H2>Heartbeat status {VER}</h2>")
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"))
)
res.append("</body></html>")
return res
body = "\n".join(res)
return web.Response(text=body, content_type="text/html")
def builderror(self, code, cause, lcause):
res = []
res.append('<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">')
res.append("<html><head>")
res.append("<title>%s %s</title>" % (code, cause))
res.append("</head><body>")
res.append("<h1>%s</h1>" % (cause))
res.append("<p>%s</p>" % lcause)
res.append("<hr>")
res.append(
"<address>hbd (Unix) Server at %s:%s</address>" % (config.get("hbd_host"), config.get("hbd_port"))
)
res.append("</body></html>")
return code, res
async def api_hosts(request):
lst = [hbdclass.Host.hosts[h].jsons() for h in hbdclass.Host.hosts]
return web.json_response(json.loads("[" + ",".join(lst) + "]"))
def do_GET(self):
xsig = 0
rqAcceptEncoding = self.headers.get("Accept-encoding", {})
headerdict = {"Content-Type": "text/html; charset = ISO-8859-1"}
qr = urllib.parse.urlparse(self.path)
qa = urllib.parse.parse_qs(qr.query)
async def api_messages(request):
lst = msgs_getter()[-30:]
return web.json_response(lst)
if qr.path == "/":
res = self.buildpage()
elif qr.path == "/c": # command on host /c?h=melschserver&c=sudo%20ls
uname = qa.get("h", [None])[0]
ucmd = qa.get("c", [None])[0]
async def cmd(request):
qa = request.rel_url.query
uname = qa.get("h")
ucmd = qa.get("c")
if not ucmd or not uname:
code, res = self.builderror(400, "Argument error", "need h= and c= arguments")
elif uname not in hbdclass.Host.hosts:
code, res = self.builderror(400, "Data error", "h=%s not found" % uname)
else:
hbdclass.Host.hosts[uname].cmds.append(("CMD", {"cmd": urllib.parse.unquote(ucmd)}))
res = self.buildhead()
res.append("cmd %s queued for host %s" % (uname, ucmd))
elif qr.path == "/d": # drop host /d?h=melschserver
uname = qa.get("h", [None])[0]
if not uname:
code, res = self.builderror(400, "Argument error", "need h= argument")
return web.Response(status=400, text="need h= and c= arguments")
if uname not in hbdclass.Host.hosts:
code, res = self.builderror(400, "Data error", "h=%s not found" % uname)
else:
return web.Response(status=400, text=f"h={uname} not found")
hbdclass.Host.hosts[uname].cmds.append(("CMD", {"cmd": urllib.parse.unquote(ucmd)}))
return web.Response(text=f"cmd {uname} queued")
async def drop(request):
qa = request.rel_url.query
uname = qa.get("h")
if not uname:
return web.Response(status=400, text="need h= argument")
if uname not in hbdclass.Host.hosts:
return web.Response(status=400, text=f"h={uname} not found")
if log:
log(uname, "dropped")
del hbdclass.Host.hosts[uname]
res = self.buildhead()
res.append("Done")
return web.Response(text="Done")
elif qr.path == "/n": # register name
uname = qa.get("h", [None])[0]
async def register(request):
qa = request.rel_url.query
uname = qa.get("h")
if not uname:
code, res = self.builderror(400, "Argument error", "need h= argument")
return web.Response(status=400, text="need h= argument")
if uname not in hbdclass.Host.hosts:
code, res = self.builderror(400, "Data error", "h=%s not found" % uname)
else:
return web.Response(status=400, text=f"h={uname} not found")
ll = hbdclass.Host.hosts[uname].registerDns()
res = self.buildhead()
res.append(ll)
if log:
log(uname, ll)
return web.Response(text=str(ll))
elif qr.path == "/u": # update
uname = urllib.parse.unquote(qa.get("h", [None])[0])
ucode = qa.get("c", [None])[0]
async def update(request):
qa = request.rel_url.query
uname = urllib.parse.unquote(qa.get("h", ""))
ucode = qa.get("c")
if not ucode or not uname:
code, res = self.builderror(400, "Argument error", "need h= and c= arguments")
elif uname != "All" and uname not in hbdclass.Host.hosts:
code, res = self.builderror(400, "Data error", "h=%s not found" % uname)
else:
res = self.buildhead()
return web.Response(status=400, text="need h= and c= arguments")
if uname != "All" and uname not in hbdclass.Host.hosts:
return web.Response(status=400, text=f"h={uname} not found")
if uname != "All":
names = [uname]
else:
names = []
for n in hbdclass.Host.hosts:
if hbdclass.Host.hosts[n].cver >= 2: # earliest version that supports update
names.append(n)
names = [n for n in hbdclass.Host.hosts if hbdclass.Host.hosts[n].cver >= 2]
out = []
for n in names:
err = None
try:
from hbd import proto
# read code from a file name, fallback to sending ucode as data
err = None
# attempt to send update command to host
r = {"csum": None, "code": ucode}
hbdclass.Host.hosts[n].cmds.append(("UPD", r))
except Exception as e:
err = str(e)
res.append("update started for %s: %s<br>" % (n, err if err else "OK"))
res.append("Done")
out.append(f"update started for {n}: {err if err else 'OK'}")
return web.Response(text="\n".join(out))
elif qr.path == "/api/0/hosts": # api access to host table
headerdict = {"Content-Type": "application/json; charset=utf-8"}
lst = []
for h in hbdclass.Host.hosts:
lst.append(hbdclass.Host.hosts[h].jsons())
res = ["[" + ",".join(lst) + "]"]
elif qr.path == "/api/0/messages": # api access to host table
headerdict = {"Content-Type": "application/json; charset=utf-8"}
lst = msgs_getter()[-30:]
res = [json.dumps(lst)]
elif qr.path == "/r": # restart
res = self.buildhead()
res.append("restart request")
xsig = 1 # signal.SIGHUP will be handled by application
async def restart(request):
# signal main application to perform restart if needed
# not implemented here - return OK
if log:
log(None, "restart request")
elif qr.path == "/live": # show live view with websockets
return web.Response(text="restart request")
async def live(request):
# render template from templates/live.html using Jinja2
env = jinja2.Environment(loader=jinja2.FileSystemLoader(config.get("templates_dir", "templates")))
host = config.get("hb_host", "localhost")
extra_scripts = '' # '<script src="/static/js/live.js"></script>'
heartbeat_ws_url = f"ws://{host}:50005/hbd"
res = templates.TemplateResponse(
"live.html ",
{
"title": "Heartbeat",
"header": "Heartbeat",
"heartbeat_ws_url": heartbeat_ws_url,
"extra_scripts": extra_scripts,
},
extra_scripts = config.get("http_extra_scripts", "")
heartbeat_ws_url = f"ws://{host}:{config.get('ws_port', 50005)}/hbd"
tmpl = env.get_template("live.html")
body = tmpl.render(
title="Heartbeat",
header="Heartbeat",
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)],
messages=msgs_getter()[-30:],
)
else:
code, res = self.builderror(404, "Not Found", "requested URL was not found on this server.")
return web.Response(text=body, content_type="text/html")
if "deflate" in rqAcceptEncoding:
headerdict["Content-Encoding"] = "deflate"
towrite = __import__("zlib").compress("\n".join(res).encode(), 6)
else:
towrite = "\n".join(res)
headerdict["Content-Length"] = len(towrite)
headerdict["Cache-Control"] = "private, must-revalidate, max-age=0"
headerdict["Expires"] = "Thu, 01 Jan 1970 00:00:00 GMT"
self.setheaders(200 if 'res' in locals() else code, headerdict)
self.wfile.write(towrite if isinstance(towrite, bytes) else towrite.encode())
async def static(request):
"""Serve files from the package static directory.
URL form: /static/<path>
"""
p = request.match_info.get("path", "")
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)))
if not target.startswith(base + os.sep) and target != base:
return web.Response(status=403, text="Forbidden")
if not os.path.exists(target) or not os.path.isfile(target):
return web.Response(status=404, text="Not Found")
logger.info("serving static file: %s", target)
return web.FileResponse(path=target)
app = web.Application()
app.add_routes(
[
web.get("/", index),
web.get("/api/0/hosts", api_hosts),
web.get("/api/0/messages", api_messages),
web.get("/c", cmd),
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),
]
)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, host, port)
await site.start()
if verbose:
print(f"HTTP server started on {host}:{port}")
if xsig:
# inform application via setting a flag on the server instance
try:
self.server.xsig = xsig
except Exception:
pass
await asyncio.Future()
finally:
await runner.cleanup()
return CustomHandler
+46
View File
@@ -0,0 +1,46 @@
"""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):
now = time.time()
for h in list(hbdclass.Host.hosts.keys()):
pmsg = []
for c in hbdclass.Host.hosts[h].connections:
conn = hbdclass.Host.hosts[h].connections[c]
if conn.state == hbdclass.Connection.DOWN:
continue
timeout = hbdclass.Host.hosts[h].interval + config.get("grace", 10)
if conn.state == hbdclass.Connection.UP and (now - conn.lastbeat) > timeout:
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.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)
+12 -20
View File
@@ -1,4 +1,5 @@
"""Notification helpers: email, pushover, mattermost, signal and dispatcher."""
import logging
from typing import Optional
import http.client
import urllib.parse
@@ -11,6 +12,7 @@ DEFAULT_PUSHPROVIDERS = ["all", "pushover", "mattermost", "signal"]
# module-level configuration set via setup()
_config = {}
logger = logging.getLogger(__name__)
def setup(cfg: dict):
@@ -27,8 +29,7 @@ def send_email(aemail, smtpserver, sender, subject, body, debug=0):
server.set_debuglevel(1)
server.sendmail(sender, aemail, body)
except Exception as e:
if debug:
print("email send failed:", e)
logger.warning("email send failed: %s", e)
try:
server.quit()
except Exception:
@@ -72,12 +73,10 @@ def pushover(token: str, user: str, msg: str, debug: int = 0) -> bool:
{"Content-type": "application/x-www-form-urlencoded"},
)
r = conn.getresponse()
if debug:
print("pushover response:", r.status, r.reason)
logger.debug("pushover response: %s %s", r.status, r.reason)
return r.status == 200
except Exception as e:
if debug:
print("pushover error:", e)
logger.error("pushover error: %s", e)
return False
@@ -98,12 +97,10 @@ def pushmattermost(host: str, token: str, channel: str, msg: str, username: str
payload["icon_url"] = icon
try:
rc = mm.webhooks.call_webhook(token, payload)
if debug:
print("mattermost rc:", rc)
logger.debug("mattermost rc: %s", rc)
return bool(rc is None or rc == "")
except Exception as e:
if debug:
print("mattermost error:", e)
logger.error("mattermost error: %s", e)
return False
@@ -113,20 +110,16 @@ def pushsignal(signal_cli_bin: str, user: str, recipient: str, msg: str, debug:
Uses subprocess to call signal-cli. Returns True if the command succeeded.
"""
CLI = [signal_cli_bin, "-u", user, "send", "-m", msg, recipient]
if debug:
print("signal cli: ", CLI)
logger.debug("signal cli: %s", CLI)
try:
res = subprocess.run(CLI, capture_output=True)
if res.returncode != 0:
if debug:
print("signal failed:", res.stderr.decode())
logger.error("signal failed: %s". res.stderr.decode())
return False
if debug:
print("signal sent:", res.stdout.decode())
logger.debug("signal sent: %s", res.stdout.decode())
return True
except Exception as e:
if debug:
print("signal exception:", e)
logger.exception("signal exception: %s", e)
return False
@@ -152,8 +145,7 @@ def pushmsg(cfg: dict, msg: str, debug: int = 0):
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)
results["signal"] = ok
if debug:
print("push results:", results)
logger.debug("push results: %s", results)
return results
+232 -38
View File
@@ -1,38 +1,88 @@
"""Server runtime: starts UDP listener, HTTP server and websocket stubs."""
import asyncio
import logging
import atexit
import time
import signal
import sys
from . import __version__
from . import udp
from . import hbdclass
from . import ws as ws_mod
logger = logging.getLogger(__name__)
msg_to_websockets = ws_mod.broadcast
async def _run_async(config):
loop = asyncio.get_running_loop()
logf = None
lastfm = ["", "", ""]
# shared runtime collections and helpers
msgs = []
# prepare runtime dependencies
import threading
import time
import hbdclass
from . import http as http_mod
from . import ws as ws_mod
from . import dns as dns_mod
from . import notify as notify_mod
notify_mod.setup(config)
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}"
msgs.append(s)
logger.info(s)
if logf:
try:
logf.write(s + "\n")
logf.flush()
except Exception as e:
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")
pick = pickle.Pickler(pickf)
pick.dump(hbdclass.Host.hosts)
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
logger.info(f"Received {sig_name}, initiating shutdown...")
loop.call_soon_threadsafe(shutdown_event.set)
# Register signal handlers
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
notify_mod.setup(config)
email = notify_mod.email
pushmsg = notify_mod.pushmsg_from_config
msg_to_websockets = ws_mod.broadcast
# UDP server endpoint (handler wired to handle_datagram with context)
bind_addr = ("0.0.0.0", config.get("hb_port", 50003))
@@ -46,7 +96,6 @@ async def _run_async(config):
email=email,
pushmsg=pushmsg,
msg_to_websockets=msg_to_websockets,
msgs=msgs,
DEBUG=config.get("debug", 0),
verbose=config.get("verbose", False),
)
@@ -57,9 +106,12 @@ async def _run_async(config):
local_addr=bind_addr,
)
# HTTP server (runs in its own thread)
# HTTP server (asyncio-based via aiohttp)
try:
handler_cls = http_mod.make_handler_class(
http_task = asyncio.create_task(
http_mod.start(
host=config.get("hbd_host", ""),
port=config.get("hbd_port", 50004),
config=config,
hbdclass=hbdclass,
msgs_getter=lambda: msgs,
@@ -73,16 +125,18 @@ async def _run_async(config):
get_now=lambda: time.time(),
VER="",
)
serv = http_mod.HttpServer((config.get("hbd_host", ""), config.get("hbd_port", 50004)), handler_cls)
http_thread = threading.Thread(target=serv.serve_forever, daemon=True)
http_thread.start()
)
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 thread
dns_mod.start_dns_thread(hbdclass, config, log=log, email=email)
logger.info("dns update thread started")
# start dns update worker (async)
dns_task = None
try:
dns_task = dns_mod.start_dns_worker(hbdclass, config, log=log, email=email, 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
try:
@@ -101,28 +155,168 @@ async def _run_async(config):
except Exception as e:
logger.exception("websocket server failed to start: %s", e)
# Start the monitor thread as a background task
try:
# run forever
await asyncio.Future()
finally:
transport.close()
try:
serv.shutdown()
except Exception:
pass
try:
ws_task.cancel()
except Exception:
pass
monitor_task = asyncio.create_task(
monitor_mod.start(
config=config,
hbdclass=hbdclass,
log=log,
email=email,
pushmsg=pushmsg,
msg_to_websockets=msg_to_websockets,
)
)
logger.info("Monitor task started")
except Exception as e:
logger.exception("monitor task failed to start: %s", e)
try:
# run forever until shutdown event is set
await shutdown_event.wait()
logger.info("Shutdown signal received, stopping services...")
except Exception as e:
logger.exception("Error in main loop: %s", e)
finally:
# Cancel all running tasks
logger.info("Cancelling tasks...")
try:
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:
try:
task.cancel()
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)
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:
try:
hbdclass.Host.dnsQ.put(None)
except Exception:
pass
try:
await asyncio.wait_for(dns_task, timeout=2.0)
logger.info("DNS worker finished")
except asyncio.TimeoutError:
logger.warning("Timeout waiting for DNS worker to finish")
dns_task.cancel()
except asyncio.CancelledError:
logger.info("DNS worker was cancelled")
except Exception as e:
logger.warning("Error awaiting DNS worker: %s", e)
finally:
# Clear queue bridge to release any held references
hbdclass.Host.dnsQ = None
except Exception as e:
logger.warning("Error stopping DNS worker: %s", e)
logger.info("All tasks cancelled")
def load_pickled_hosts(config, hbdclass):
"""Load pickled hosts from file, if available."""
global lastfm, msgs
import os
import pickle
pickfile = config.get("pickfile", "hbd.pickle")
dyndnshosts = config.get("dyndnshosts", [])
watchhosts = config.get("watchhosts", [])
drophosts = config.get("drophosts", [])
if 1 and os.path.exists(pickfile):
if config.get("verbose", False):
logger.info("opening pickls %s", pickfile)
pickf = open(pickfile, "rb")
pick = pickle.Unpickler(pickf)
try:
hbdclass.Host.hosts = pick.load()
msgs = pick.load()
try:
lastfm = pick.load()
except:
lastfm = ["", "", ""]
pickf.close()
except Exception as e:
print(("load pickled failed: %s" % e))
os.unlink(pickfile)
hbdclass.Connection.htab = {}
for h in list(hbdclass.Host.hosts.keys()):
hbdclass.Host.hosts[h].dyn = h in dyndnshosts
hbdclass.Host.hosts[h].watched = h in watchhosts
hbdclass.Host.hosts[h].fixup()
for h in drophosts:
if h in hbdclass.Host.hosts:
del hbdclass.Host.hosts[h]
if config.get("verbose", False):
logger.info("%s pickled hosts loaded", len(hbdclass.Host.hosts))
else:
if config.get("verbose", False):
logger.info("no pickled data")
def run(config):
"""Start the hbd service (blocking).
This is a thin wrapper around asyncio.run to host the async services.
Manually manages the event loop to ensure clean shutdown.
"""
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)
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:
asyncio.run(_run_async(config))
loop.run_until_complete(_run_async(config))
except KeyboardInterrupt:
logger.info("Shutting down (KeyboardInterrupt)")
logger.info("Received KeyboardInterrupt, shutting down...")
except Exception as e:
logger.exception("Unhandled exception in main: %s", e)
finally:
cleanup_function(config)
logger.info("hbd shutdown complete")
if logf and logf != sys.stderr:
try:
logf.close()
except Exception:
pass
# Explicitly close the loop
try:
# Cancel all remaining tasks
pending = asyncio.all_tasks(loop)
for task in pending:
task.cancel()
# Run one more cycle to process cancellations
if pending:
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
except Exception:
pass
finally:
loop.close()
# Exit
os._exit(0)
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

+142
View File
@@ -0,0 +1,142 @@
/* http://www.designcouch.com/home/why/2014/04/23/pure-css-drawer-menu/ */
* {
box-sizing: border-box;
/* adds animation for all transitions */
transition: .25s ease-in-out;
/* margin: 0;
padding: 0; */
/* text-size-adjust: none; */
}
/* Makes sure that everything is 100% height */
html,
body {
height: 100%;
overflow: hidden;
color:#303030;
background:#fafafa top left repeat-y;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", "Helvetica", "Arial", sans-serif;
font-size:100%;
margin: 0;
}
#drawer-toggle {
position: absolute;
opacity: 0;
}
#drawer ul a {
display: block;
padding: 10px;
color: #c7c7c7;
text-decoration: none;
}
#drawer-toggle-label {
user-select: none;
left: 0px;
height: 50px;
width: 50px;
display: block;
position: fixed;
color: rgb(242, 242, 242);
background: rgba(255, 255, 255, .0);
z-index: 1;
}
/* adds our "hamburger" menu icon */
#drawer-toggle-label:before {
content: '';
display: block;
position: absolute;
height: 2px;
width: 24px;
background: #8d8d8d;
left: 13px;
top: 18px;
box-shadow: 0 6px 0 #8d8d8d, 0 12px 0 #8d8d8d;
}
header {
width: 100%;
position: fixed;
left: 0px;
background: #efefef;
padding: 10px 10px 10px 50px;
font-size: 30px;
line-height: 30px;
z-index: 0;
}
/* drawer menu pane - note the 0px width */
#drawer {
position: fixed;
top: 0;
width: 150px;
left: -150px;
height: 100%;
background: #2f2f2f;
overflow-x: hidden;
overflow-y: scroll;
padding: 0px;
}
@media all and (min-resolution: 150dpi) {
header {
font-size: 30px;
/* line-height: 45px; */
}
#drawer {
font-size: 120%;
}
/* body {
background-color: lightyellow;
} */
}
/* actual page content pane */
#content {
margin-left: 0px;
margin-top: 30px;
/* width: 100%; */
height: calc(100% - 50px);
overflow-x: hidden;
overflow-y: scroll;
padding: 20px;
flex: auto;
}
/* checked styles (menu open state) */
#drawer-toggle:checked ~ #drawer-toggle-label {
height: 100%;
width: calc(100% - 150px);
color: rgb(242, 242, 242);
background: rgba(255, 255, 255, .8);
}
#drawer-toggle:checked ~ #drawer-toggle-label,
#drawer-toggle:checked ~ header {
left: 150px;
}
#drawer-toggle:checked ~ #drawer {
left: 0px;
}
#drawer-toggle:checked ~ #content {
margin-left: 150px;
}
#copyright {
font-size: 9px;
float: left;
}
-2
View File
@@ -71,7 +71,6 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
- email: callable(subject, message)
- pushmsg: callable(message)
- msg_to_websockets: callable(typ, data)
- msgs: list for storing message strings
- DEBUG, verbose
"""
if not msg:
@@ -83,7 +82,6 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
email = ctx.get("email")
pushmsg = ctx.get("pushmsg")
msg_to_websockets = ctx.get("msg_to_websockets")
msgs = ctx.get("msgs")
DEBUG = ctx.get("DEBUG", 0)
verbose = ctx.get("verbose", False)
+23 -5
View File
@@ -19,10 +19,14 @@ _get_msgs: Optional[Callable[[], Iterable]] = None
_verbose = False
async def _handler(websocket, path):
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 = websocket.remote_address
remote_address = getattr(websocket, "remote_address", None)
if path is None:
path = getattr(websocket, "path", None)
if _verbose:
logger.info("DBG ws_serve: %s: %s", remote_address, path)
try:
@@ -72,15 +76,18 @@ 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"])
ws_server = websockets.serve(_handler, host, ws_port) #, subprotocols=["hbd"])
websockets_logger = logging.getLogger("websockets.server")
websockets_logger.setLevel(logging.INFO)
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
@@ -89,6 +96,16 @@ async def start(host: str, ws_port: int, wss_port: Optional[int] = None, ssl_con
# 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:
@@ -98,12 +115,13 @@ def broadcast(typ: str, data) -> bool:
connected websockets. Returns False if server was not running.
"""
global _loop
if not _loop:
return False
jmsg = json.dumps({"type": typ, "data": data})
to_close = []
for ws in list(_connections):
if ws.closed:
if ws.state != websockets.protocol.State.OPEN:
to_close.append(ws)
continue
try:
+2 -1
View File
@@ -10,7 +10,8 @@ Description-Content-Type: text/markdown
Requires-Dist: websockets>=13.2
Requires-Dist: mattermostdriver>=7.3.0
Requires-Dist: PyYAML>=6.0
Requires-Dist: fastapi>=0.95.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"
+2
View File
@@ -4,7 +4,9 @@ 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
+2 -1
View File
@@ -1,7 +1,8 @@
websockets>=13.2
mattermostdriver>=7.3.0
PyYAML>=6.0
fastapi>=0.95.0
aiohttp>=3.8
Jinja2>=3.1.0
[dev]
pytest>=7.0
-7
View File
@@ -1,7 +0,0 @@
#!/bin/sh
mkdir $HOME/bin 2>/dev/null
cp -rp hbd hbdclass.py hbc daemon lockfile $HOME/bin/
echo "use: $HOME/bin/hbc -d hbd.wrede.ca to start a heartbeat"
-377
View File
@@ -1,377 +0,0 @@
# -*- coding: utf-8 -*-
"""
lockfile.py - Platform-independent advisory file locks.
Requires Python 2.5 unless you apply 2.4.diff
Locking is done on a per-thread basis instead of a per-process basis.
Usage:
>>> lock = LockFile('somefile')
>>> try:
... lock.acquire()
... except AlreadyLocked:
... print 'somefile', 'is locked already.'
... except LockFailed:
... print 'somefile', 'can\\'t be locked.'
... else:
... print 'got lock'
got lock
>>> print lock.is_locked()
True
>>> lock.release()
>>> lock = LockFile('somefile')
>>> print lock.is_locked()
False
>>> with lock:
... print lock.is_locked()
True
>>> print lock.is_locked()
False
>>> lock = LockFile('somefile')
>>> # It is okay to lock twice from the same thread...
>>> with lock:
... lock.acquire()
...
>>> # Though no counter is kept, so you can't unlock multiple times...
>>> print lock.is_locked()
False
Exceptions:
Error - base class for other exceptions
LockError - base class for all locking exceptions
AlreadyLocked - Another thread or process already holds the lock
LockFailed - Lock failed for some other reason
UnlockError - base class for all unlocking exceptions
AlreadyUnlocked - File was not locked.
NotMyLock - File was locked but not by the current thread/process
"""
from __future__ import absolute_import
import functools
import os
import socket
import threading
import warnings
# Work with PEP8 and non-PEP8 versions of threading module.
if not hasattr(threading, "current_thread"):
threading.current_thread = threading.currentThread
if not hasattr(threading.Thread, "get_name"):
threading.Thread.get_name = threading.Thread.getName
__all__ = [
"Error",
"LockError",
"LockTimeout",
"AlreadyLocked",
"LockFailed",
"UnlockError",
"NotLocked",
"NotMyLock",
"LinkFileLock",
"MkdirFileLock",
"SQLiteFileLock",
"LockBase",
"locked",
]
class Error(Exception):
"""
Base class for other exceptions.
>>> try:
... raise Error
... except Exception:
... pass
"""
pass
class LockError(Error):
"""
Base class for error arising from attempts to acquire the lock.
>>> try:
... raise LockError
... except Error:
... pass
"""
pass
class LockTimeout(LockError):
"""Raised when lock creation fails within a user-defined period of time.
>>> try:
... raise LockTimeout
... except LockError:
... pass
"""
pass
class AlreadyLocked(LockError):
"""Some other thread/process is locking the file.
>>> try:
... raise AlreadyLocked
... except LockError:
... pass
"""
pass
class LockFailed(LockError):
"""Lock file creation failed for some other reason.
>>> try:
... raise LockFailed
... except LockError:
... pass
"""
pass
class UnlockError(Error):
"""
Base class for errors arising from attempts to release the lock.
>>> try:
... raise UnlockError
... except Error:
... pass
"""
pass
class NotLocked(UnlockError):
"""Raised when an attempt is made to unlock an unlocked file.
>>> try:
... raise NotLocked
... except UnlockError:
... pass
"""
pass
class NotMyLock(UnlockError):
"""Raised when an attempt is made to unlock a file someone else locked.
>>> try:
... raise NotMyLock
... except UnlockError:
... pass
"""
pass
class _SharedBase(object):
def __init__(self, path):
self.path = path
def acquire(self, timeout=None):
"""
Acquire the lock.
* If timeout is omitted (or None), wait forever trying to lock the
file.
* If timeout > 0, try to acquire the lock for that many seconds. If
the lock period expires and the file is still locked, raise
LockTimeout.
* If timeout <= 0, raise AlreadyLocked immediately if the file is
already locked.
"""
raise NotImplemented("implement in subclass")
def release(self):
"""
Release the lock.
If the file is not locked, raise NotLocked.
"""
raise NotImplemented("implement in subclass")
def __enter__(self):
"""
Context manager support.
"""
self.acquire()
return self
def __exit__(self, *_exc):
"""
Context manager support.
"""
self.release()
def __repr__(self):
return "<%s: %r>" % (self.__class__.__name__, self.path)
class LockBase(_SharedBase):
"""Base class for platform-specific lock classes."""
def __init__(self, path, threaded=True, timeout=None):
"""
>>> lock = LockBase('somefile')
>>> lock = LockBase('somefile', threaded=False)
"""
super(LockBase, self).__init__(path)
self.lock_file = os.path.abspath(path) + ".lock"
self.hostname = socket.gethostname()
self.pid = os.getpid()
if threaded:
t = threading.current_thread()
# Thread objects in Python 2.4 and earlier do not have ident
# attrs. Worm around that.
ident = getattr(t, "ident", hash(t))
self.tname = "-%x" % (ident & 0xFFFFFFFF)
else:
self.tname = ""
dirname = os.path.dirname(self.lock_file)
# unique name is mostly about the current process, but must
# also contain the path -- otherwise, two adjacent locked
# files conflict (one file gets locked, creating lock-file and
# unique file, the other one gets locked, creating lock-file
# and overwriting the already existing lock-file, then one
# gets unlocked, deleting both lock-file and unique file,
# finally the last lock errors out upon releasing.
self.unique_name = os.path.join(
dirname,
"%s%s.%s%s" % (self.hostname, self.tname, self.pid, hash(self.path)),
)
self.timeout = timeout
def is_locked(self):
"""
Tell whether or not the file is locked.
"""
raise NotImplemented("implement in subclass")
def i_am_locking(self):
"""
Return True if this object is locking the file.
"""
raise NotImplemented("implement in subclass")
def break_lock(self):
"""
Remove a lock. Useful if a locking thread failed to unlock.
"""
raise NotImplemented("implement in subclass")
def __repr__(self):
return "<%s: %r -- %r>" % (self.__class__.__name__, self.unique_name, self.path)
def _fl_helper(cls, mod, *args, **kwds):
warnings.warn(
"Import from %s module instead of lockfile package" % mod,
DeprecationWarning,
stacklevel=2,
)
# This is a bit funky, but it's only for awhile. The way the unit tests
# are constructed this function winds up as an unbound method, so it
# actually takes three args, not two. We want to toss out self.
if not isinstance(args[0], str):
# We are testing, avoid the first arg
args = args[1:]
if len(args) == 1 and not kwds:
kwds["threaded"] = True
return cls(*args, **kwds)
def LinkFileLock(*args, **kwds):
"""Factory function provided for backwards compatibility.
Do not use in new code. Instead, import LinkLockFile from the
lockfile.linklockfile module.
"""
from . import linklockfile
return _fl_helper(linklockfile.LinkLockFile, "lockfile.linklockfile", *args, **kwds)
def MkdirFileLock(*args, **kwds):
"""Factory function provided for backwards compatibility.
Do not use in new code. Instead, import MkdirLockFile from the
lockfile.mkdirlockfile module.
"""
from . import mkdirlockfile
return _fl_helper(
mkdirlockfile.MkdirLockFile, "lockfile.mkdirlockfile", *args, **kwds
)
def SQLiteFileLock(*args, **kwds):
"""Factory function provided for backwards compatibility.
Do not use in new code. Instead, import SQLiteLockFile from the
lockfile.mkdirlockfile module.
"""
from . import sqlitelockfile
return _fl_helper(
sqlitelockfile.SQLiteLockFile, "lockfile.sqlitelockfile", *args, **kwds
)
def locked(path, timeout=None):
"""Decorator which enables locks for decorated function.
Arguments:
- path: path for lockfile.
- timeout (optional): Timeout for acquiring lock.
Usage:
@locked('/var/run/myname', timeout=0)
def myname(...):
...
"""
def decor(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
lock = FileLock(path, timeout=timeout)
lock.acquire()
try:
return func(*args, **kwargs)
finally:
lock.release()
return wrapper
return decor
if hasattr(os, "link"):
from . import linklockfile as _llf
LockFile = _llf.LinkLockFile
else:
from . import mkdirlockfile as _mlf
LockFile = _mlf.MkdirLockFile
FileLock = LockFile
-73
View File
@@ -1,73 +0,0 @@
from __future__ import absolute_import
import time
import os
from . import LockBase, LockFailed, NotLocked, NotMyLock, LockTimeout, AlreadyLocked
class LinkLockFile(LockBase):
"""Lock access to a file using atomic property of link(2).
>>> lock = LinkLockFile('somefile')
>>> lock = LinkLockFile('somefile', threaded=False)
"""
def acquire(self, timeout=None):
try:
open(self.unique_name, "wb").close()
except IOError:
raise LockFailed("failed to create %s" % self.unique_name)
timeout = timeout if timeout is not None else self.timeout
end_time = time.time()
if timeout is not None and timeout > 0:
end_time += timeout
while True:
# Try and create a hard link to it.
try:
os.link(self.unique_name, self.lock_file)
except OSError:
# Link creation failed. Maybe we've double-locked?
nlinks = os.stat(self.unique_name).st_nlink
if nlinks == 2:
# The original link plus the one I created == 2. We're
# good to go.
return
else:
# Otherwise the lock creation failed.
if timeout is not None and time.time() > end_time:
os.unlink(self.unique_name)
if timeout > 0:
raise LockTimeout(
"Timeout waiting to acquire" " lock for %s" % self.path
)
else:
raise AlreadyLocked("%s is already locked" % self.path)
time.sleep(timeout is not None and timeout / 10 or 0.1)
else:
# Link creation succeeded. We're good to go.
return
def release(self):
if not self.is_locked():
raise NotLocked("%s is not locked" % self.path)
elif not os.path.exists(self.unique_name):
raise NotMyLock("%s is locked, but not by me" % self.path)
os.unlink(self.unique_name)
os.unlink(self.lock_file)
def is_locked(self):
return os.path.exists(self.lock_file)
def i_am_locking(self):
return (
self.is_locked()
and os.path.exists(self.unique_name)
and os.stat(self.unique_name).st_nlink == 2
)
def break_lock(self):
if os.path.exists(self.lock_file):
os.unlink(self.lock_file)
-81
View File
@@ -1,81 +0,0 @@
from __future__ import absolute_import, division
import time
import os
import sys
import errno
from . import LockBase, LockFailed, NotLocked, NotMyLock, LockTimeout, AlreadyLocked
class MkdirLockFile(LockBase):
"""Lock file by creating a directory."""
def __init__(self, path, threaded=True, timeout=None):
"""
>>> lock = MkdirLockFile('somefile')
>>> lock = MkdirLockFile('somefile', threaded=False)
"""
LockBase.__init__(self, path, threaded, timeout)
# Lock file itself is a directory. Place the unique file name into
# it.
self.unique_name = os.path.join(
self.lock_file, "%s.%s%s" % (self.hostname, self.tname, self.pid)
)
def acquire(self, timeout=None):
timeout = timeout if timeout is not None else self.timeout
end_time = time.time()
if timeout is not None and timeout > 0:
end_time += timeout
if timeout is None:
wait = 0.1
else:
wait = max(0, timeout / 10)
while True:
try:
os.mkdir(self.lock_file)
except OSError:
err = sys.exc_info()[1]
if err.errno == errno.EEXIST:
# Already locked.
if os.path.exists(self.unique_name):
# Already locked by me.
return
if timeout is not None and time.time() > end_time:
if timeout > 0:
raise LockTimeout(
"Timeout waiting to acquire" " lock for %s" % self.path
)
else:
# Someone else has the lock.
raise AlreadyLocked("%s is already locked" % self.path)
time.sleep(wait)
else:
# Couldn't create the lock for some other reason
raise LockFailed("failed to create %s" % self.lock_file)
else:
open(self.unique_name, "wb").close()
return
def release(self):
if not self.is_locked():
raise NotLocked("%s is not locked" % self.path)
elif not os.path.exists(self.unique_name):
raise NotMyLock("%s is locked, but not by me" % self.path)
os.unlink(self.unique_name)
os.rmdir(self.lock_file)
def is_locked(self):
return os.path.exists(self.lock_file)
def i_am_locking(self):
return self.is_locked() and os.path.exists(self.unique_name)
def break_lock(self):
if os.path.exists(self.lock_file):
for name in os.listdir(self.lock_file):
os.unlink(os.path.join(self.lock_file, name))
os.rmdir(self.lock_file)
-188
View File
@@ -1,188 +0,0 @@
# -*- coding: utf-8 -*-
# pidlockfile.py
#
# Copyright © 20082009 Ben Finney <ben+python@benfinney.id.au>
#
# This is free software: you may copy, modify, and/or distribute this work
# under the terms of the Python Software Foundation License, version 2 or
# later as published by the Python Software Foundation.
# No warranty expressed or implied. See the file LICENSE.PSF-2 for details.
""" Lockfile behaviour implemented via Unix PID files.
"""
from __future__ import absolute_import
import errno
import os
import time
from . import LockBase, AlreadyLocked, LockFailed, NotLocked, NotMyLock, LockTimeout
class PIDLockFile(LockBase):
""" Lockfile implemented as a Unix PID file.
The lock file is a normal file named by the attribute `path`.
A lock's PID file contains a single line of text, containing
the process ID (PID) of the process that acquired the lock.
>>> lock = PIDLockFile('somefile')
>>> lock = PIDLockFile('somefile')
"""
def __init__(self, path, threaded=False, timeout=None):
# pid lockfiles don't support threaded operation, so always force
# False as the threaded arg.
LockBase.__init__(self, path, False, timeout)
self.unique_name = self.path
def read_pid(self):
""" Get the PID from the lock file.
"""
return read_pid_from_pidfile(self.path)
def is_locked(self):
""" Test if the lock is currently held.
The lock is held if the PID file for this lock exists.
"""
return os.path.exists(self.path)
def i_am_locking(self):
""" Test if the lock is held by the current process.
Returns ``True`` if the current process ID matches the
number stored in the PID file.
"""
return self.is_locked() and os.getpid() == self.read_pid()
def acquire(self, timeout=None):
""" Acquire the lock.
Creates the PID file for this lock, or raises an error if
the lock could not be acquired.
"""
timeout = timeout if timeout is not None else self.timeout
end_time = time.time()
if timeout is not None and timeout > 0:
end_time += timeout
while True:
try:
write_pid_to_pidfile(self.path)
except OSError as exc:
if exc.errno == errno.EEXIST:
# The lock creation failed. Maybe sleep a bit.
if time.time() > end_time:
if timeout is not None and timeout > 0:
raise LockTimeout(
"Timeout waiting to acquire" " lock for %s" % self.path
)
else:
raise AlreadyLocked("%s is already locked" % self.path)
time.sleep(timeout is not None and timeout / 10 or 0.1)
else:
raise LockFailed("failed to create %s" % self.path)
else:
return
def release(self):
""" Release the lock.
Removes the PID file to release the lock, or raises an
error if the current process does not hold the lock.
"""
if not self.is_locked():
raise NotLocked("%s is not locked" % self.path)
if not self.i_am_locking():
raise NotMyLock("%s is locked, but not by me" % self.path)
remove_existing_pidfile(self.path)
def break_lock(self):
""" Break an existing lock.
Removes the PID file if it already exists, otherwise does
nothing.
"""
remove_existing_pidfile(self.path)
def read_pid_from_pidfile(pidfile_path):
""" Read the PID recorded in the named PID file.
Read and return the numeric PID recorded as text in the named
PID file. If the PID file cannot be read, or if the content is
not a valid PID, return ``None``.
"""
pid = None
try:
pidfile = open(pidfile_path, "r")
except IOError:
pass
else:
# According to the FHS 2.3 section on PID files in /var/run:
#
# The file must consist of the process identifier in
# ASCII-encoded decimal, followed by a newline character.
#
# Programs that read PID files should be somewhat flexible
# in what they accept; i.e., they should ignore extra
# whitespace, leading zeroes, absence of the trailing
# newline, or additional lines in the PID file.
line = pidfile.readline().strip()
try:
pid = int(line)
except ValueError:
pass
pidfile.close()
return pid
def write_pid_to_pidfile(pidfile_path):
""" Write the PID in the named PID file.
Get the numeric process ID (PID) of the current process
and write it to the named file as a line of text.
"""
open_flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY
open_mode = 0o644
pidfile_fd = os.open(pidfile_path, open_flags, open_mode)
pidfile = os.fdopen(pidfile_fd, "w")
# According to the FHS 2.3 section on PID files in /var/run:
#
# The file must consist of the process identifier in
# ASCII-encoded decimal, followed by a newline character. For
# example, if crond was process number 25, /var/run/crond.pid
# would contain three characters: two, five, and newline.
pid = os.getpid()
pidfile.write("%s\n" % pid)
pidfile.close()
def remove_existing_pidfile(pidfile_path):
""" Remove the named PID file if it exists.
Removing a PID file that doesn't already exist puts us in the
desired state, so we ignore the condition if the file does not
exist.
"""
try:
os.remove(pidfile_path)
except OSError as exc:
if exc.errno == errno.ENOENT:
pass
else:
raise
-162
View File
@@ -1,162 +0,0 @@
from __future__ import absolute_import, division
import time
import os
try:
unicode
except NameError:
unicode = str
from . import LockBase, NotLocked, NotMyLock, LockTimeout, AlreadyLocked
class SQLiteLockFile(LockBase):
"Demonstrate SQL-based locking."
testdb = None
def __init__(self, path, threaded=True, timeout=None):
"""
>>> lock = SQLiteLockFile('somefile')
>>> lock = SQLiteLockFile('somefile', threaded=False)
"""
LockBase.__init__(self, path, threaded, timeout)
self.lock_file = unicode(self.lock_file)
self.unique_name = unicode(self.unique_name)
if SQLiteLockFile.testdb is None:
import tempfile
_fd, testdb = tempfile.mkstemp()
os.close(_fd)
os.unlink(testdb)
del _fd, tempfile
SQLiteLockFile.testdb = testdb
import sqlite3
self.connection = sqlite3.connect(SQLiteLockFile.testdb)
c = self.connection.cursor()
try:
c.execute(
"create table locks"
"("
" lock_file varchar(32),"
" unique_name varchar(32)"
")"
)
except sqlite3.OperationalError:
pass
else:
self.connection.commit()
import atexit
atexit.register(os.unlink, SQLiteLockFile.testdb)
def acquire(self, timeout=None):
timeout = timeout if timeout is not None else self.timeout
end_time = time.time()
if timeout is not None and timeout > 0:
end_time += timeout
if timeout is None:
wait = 0.1
elif timeout <= 0:
wait = 0
else:
wait = timeout / 10
cursor = self.connection.cursor()
while True:
if not self.is_locked():
# Not locked. Try to lock it.
cursor.execute(
"insert into locks"
" (lock_file, unique_name)"
" values"
" (?, ?)",
(self.lock_file, self.unique_name),
)
self.connection.commit()
# Check to see if we are the only lock holder.
cursor.execute(
"select * from locks" " where unique_name = ?", (self.unique_name,)
)
rows = cursor.fetchall()
if len(rows) > 1:
# Nope. Someone else got there. Remove our lock.
cursor.execute(
"delete from locks" " where unique_name = ?",
(self.unique_name,),
)
self.connection.commit()
else:
# Yup. We're done, so go home.
return
else:
# Check to see if we are the only lock holder.
cursor.execute(
"select * from locks" " where unique_name = ?", (self.unique_name,)
)
rows = cursor.fetchall()
if len(rows) == 1:
# We're the locker, so go home.
return
# Maybe we should wait a bit longer.
if timeout is not None and time.time() > end_time:
if timeout > 0:
# No more waiting.
raise LockTimeout(
"Timeout waiting to acquire" " lock for %s" % self.path
)
else:
# Someone else has the lock and we are impatient..
raise AlreadyLocked("%s is already locked" % self.path)
# Well, okay. We'll give it a bit longer.
time.sleep(wait)
def release(self):
if not self.is_locked():
raise NotLocked("%s is not locked" % self.path)
if not self.i_am_locking():
raise NotMyLock(
"%s is locked, but not by me (by %s)"
% (self.unique_name, self._who_is_locking())
)
cursor = self.connection.cursor()
cursor.execute(
"delete from locks" " where unique_name = ?", (self.unique_name,)
)
self.connection.commit()
def _who_is_locking(self):
cursor = self.connection.cursor()
cursor.execute(
"select unique_name from locks" " where lock_file = ?", (self.lock_file,)
)
return cursor.fetchone()[0]
def is_locked(self):
cursor = self.connection.cursor()
cursor.execute("select * from locks" " where lock_file = ?", (self.lock_file,))
rows = cursor.fetchall()
return not not rows
def i_am_locking(self):
cursor = self.connection.cursor()
cursor.execute(
"select * from locks" " where lock_file = ?" " and unique_name = ?",
(self.lock_file, self.unique_name),
)
return not not cursor.fetchall()
def break_lock(self):
cursor = self.connection.cursor()
cursor.execute("delete from locks" " where lock_file = ?", (self.lock_file,))
self.connection.commit()
-70
View File
@@ -1,70 +0,0 @@
from __future__ import absolute_import
import os
import time
from . import LockBase, NotLocked, NotMyLock, LockTimeout, AlreadyLocked
class SymlinkLockFile(LockBase):
"""Lock access to a file using symlink(2)."""
def __init__(self, path, threaded=True, timeout=None):
# super(SymlinkLockFile).__init(...)
LockBase.__init__(self, path, threaded, timeout)
# split it back!
self.unique_name = os.path.split(self.unique_name)[1]
def acquire(self, timeout=None):
# Hopefully unnecessary for symlink.
# try:
# open(self.unique_name, "wb").close()
# except IOError:
# raise LockFailed("failed to create %s" % self.unique_name)
timeout = timeout if timeout is not None else self.timeout
end_time = time.time()
if timeout is not None and timeout > 0:
end_time += timeout
while True:
# Try and create a symbolic link to it.
try:
os.symlink(self.unique_name, self.lock_file)
except OSError:
# Link creation failed. Maybe we've double-locked?
if self.i_am_locking():
# Linked to out unique name. Proceed.
return
else:
# Otherwise the lock creation failed.
if timeout is not None and time.time() > end_time:
if timeout > 0:
raise LockTimeout(
"Timeout waiting to acquire" " lock for %s" % self.path
)
else:
raise AlreadyLocked("%s is already locked" % self.path)
time.sleep(timeout / 10 if timeout is not None else 0.1)
else:
# Link creation succeeded. We're good to go.
return
def release(self):
if not self.is_locked():
raise NotLocked("%s is not locked" % self.path)
elif not self.i_am_locking():
raise NotMyLock("%s is locked, but not by me" % self.path)
os.unlink(self.lock_file)
def is_locked(self):
return os.path.islink(self.lock_file)
def i_am_locking(self):
return (
os.path.islink(self.lock_file)
and os.readlink(self.lock_file) == self.unique_name
)
def break_lock(self):
if os.path.islink(self.lock_file): # exists && link
os.unlink(self.lock_file)
-58
View File
@@ -1,58 +0,0 @@
#!/usr/bin/env python
import string, os
VER = "mlog 1.00"
hosts = ["weekend", "wingbat"]
URI = "/~andreas/private/mlog.cgi?"
print "Content-type: text/html"
print "max-age: 0"
print "expires: 0"
print "pragma: no-cache"
print ""
print "<HTML>"
print "<HEAD>"
print "<TITLE>"
print "Motion Log"
print "</TITLE>"
print "<H2>Motion Log</H2>"
print "</HEAD>"
print '<BODY BGCOLOR="#FFFFFF" LINK="#008000" VLINK="#008000" BACKGROUND="/~andreas/images/tile.marble.gif">'
print "<pre>"
def hstlines(host):
cmd = "/sbin/ping -c 1 -w 2 %s 2>&1 >/dev/null" % host
r = os.system(cmd)
if r == 0:
p = os.popen("rsh %s tail -200 .heyu/logs/motion.log | grep -v again | tail -32" % host, "r")
l = p.readlines()
else:
l = ["<B>Host %s is unreachable</B>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;" % host]
return l
rep = []
for host in hosts:
rep.append(hstlines(host))
print '<table cellpading="0" cellspacing="0" border="0" style="color:black;text-align:left">'
i = 0
print "<tr><th>%s</th></tr>" % string.join(hosts, '</th><th>')
while 1:
line = []
f = 0
for h in rep:
try:
line.append(h[i][:-1])
f = 1
except:
line.append("")
if f == 0:
break
print "<tr><td>%s</td></tr>" % string.join(line, '&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</td><td>')
i += 1
print '</table></pre>'
execfile("/home/andreas/cgi-bin/trailer.py")
-58
View File
@@ -1,58 +0,0 @@
#!/usr/bin/env python3
# Monitor Interfaces, send hb msg when add changes
import time
import os
SLEEP = 60
SRV = "colo2.wapanafa.org"
DBG = 0
home = os.environ.get("HOME", "/var/tmp")
HBC = "%s/bin/hbc" % home
IFS = []
f = os.popen("/sbin/ifconfig -a 2>/dev/null", "r")
for l in f.readlines():
if len(l) > 1 and not l[0] in [" ", "\t"]:
r = l.split()
if DBG:
print(r)
if r[0][-1] == ":":
r[0] = r[0][:-1]
if r[0][:2] == "lo":
continue
IFS.append(r[0])
if DBG:
print(IFS)
addrs = {}
for I in IFS:
addrs[I] = ""
while 1:
for I in IFS:
f = os.popen("/sbin/ifconfig %s 2>/dev/null" % I, "r")
ifaddrs = []
for l in f.readlines():
r = l.split()
if DBG > 1:
print("x2", r)
if len(r) == 0 or (r[0] != "inet" and r[0] != "inet6"):
continue
if r[1].find("addr:") == 0:
ifaddr = r[1][5:]
else:
ifaddr = r[1]
ifaddrs.append(ifaddr)
if ifaddrs != [] and ifaddrs != addrs[I]:
msg = '%s -m "ifadd %s %s" %s' % (HBC, I, ",".join(ifaddrs), SRV)
if DBG:
print(msg)
else:
os.system(msg)
addrs[I] = ifaddrs
f.close()
time.sleep(SLEEP)
Generated
-1298
View File
File diff suppressed because it is too large Load Diff
-30
View File
@@ -1,30 +0,0 @@
#!/usr/bin/python
import sys
import http.client, urllib.request, urllib.parse, urllib.error
def pushover(msg):
conn = http.client.HTTPSConnection("api.pushover.net:443")
conn.request(
"POST",
"/1/messages.json",
urllib.parse.urlencode(
{
"token": "ac7NLX2rPjXFareeDgLpXNoDf4iFmf",
"user": "uDhH33UjQQDYtNzJb1ThRiWb9ingGK",
"message": msg,
}
),
{"Content-type": "application/x-www-form-urlencoded"},
)
r1 = conn.getresponse()
# print r1.status, r1.reason
return r1.status == 200
v = " ".join(sys.argv[1:])
if pushover(v):
print("delivered")
else:
print("NOT delivered")
-56
View File
@@ -1,56 +0,0 @@
#!/usr/bin/python
import sys
import http.client, urllib.request, urllib.parse, urllib.error
import getopt
def pushover(msg, title=""):
conn = http.client.HTTPSConnection("api.pushover.net:443")
conn.request(
"POST",
"/1/messages.json",
urllib.parse.urlencode(
{
"token": "aNY2xeYydxzabzihTjb3P2LMHhqhr2",
"user": "uDhH33UjQQDYtNzJb1ThRiWb9ingGK",
"message": msg,
"title": title,
}
),
{"Content-type": "application/x-www-form-urlencoded"},
)
r1 = conn.getresponse()
# print r1.status, r1.reason
return r1.status == 200
#
# Main
#
helpflag = False
verbose = False
title = "Nagios"
optslist, args = [], []
try:
optslist, args = getopt.getopt(sys.argv[1:], "ht:v")
except getopt.error as cause:
helpflag = True
lastyear = 0
for o, a in optslist:
if o == "-v":
verbose = True
elif o == "-t":
title = a
v = " ".join(args)
rc = pushover(v, title)
if verbose:
if rc:
print("delivered")
else:
print("NOT delivered")
+8 -3
View File
@@ -3,8 +3,8 @@ requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "heartbeat"
version = "0.1.0"
name = "hbd"
version = "5.0"
description = "Heartbeat daemon (hbd) — receive heartbeats and act on them"
readme = "README.md"
requires-python = ">=3.10"
@@ -19,7 +19,8 @@ dependencies = [
"websockets>=13.2",
"mattermostdriver>=7.3.0",
"PyYAML>=6.0",
"Jinja2>=3.1.0",s
"aiohttp>=3.8",
"Jinja2>=3.1.0",
"fastapi>=0.95.0",
]
@@ -29,6 +30,10 @@ dev = [
"pytest-cov>=4.0",
"flake8>=5.0",
"mypy>=1.10",
"black>=23.0",
"isort>=5.0",
"re-commit>=3.0",
"tox>=4.0",
]
[project.scripts]
-9
View File
@@ -1,9 +0,0 @@
# Development requirements
pytest>=7.0
pytest-cov>=4.0
flake8>=5.0
mypy>=1.10
black>=23.0
isort>=5.0
pre-commit>=3.0
tox>=4.0
-2
View File
@@ -1,2 +0,0 @@
websockets>=13.2
mattermostdriver>=7.3.0
+4
View File
@@ -0,0 +1,4 @@
key "rndc-key" {
algorithm hmac-md5;
secret "qlGa+AYKtyOgWNuozqECMw==";
};
-13
View File
@@ -1,13 +0,0 @@
#!/bin/sh
D=$(dirname $0)
pkill -f $D/hbc 2>/dev/null
sleep 1
while true; do
ping -qAW 6 -c 2 1.1.1.1
if [ $? -eq 0 ]; then
break
fi
done
$D/hbc $@
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
uv version --bump patch
VER=$(uv version --short)
sed -i "s/__version__ = \"[0-9.]*\"\(.*\)$/__version__ = \"$VER\"\1/" moninbox/const.py
# commit pyproject.toml
git commit -m "version $VER" pyproject.toml moninbox/const.py
git push
# tag version
git tag -a v$VER -m "Version $VER"
git push --tags
-265
View File
@@ -1,265 +0,0 @@
#!/usr/bin/env python
#
RCSID="$Id: selfcheck,v 1.3 2006/11/08 15:42:17 andreas Exp $"
# check internet connectivity
#
import os, sys, string, time, socket
PPPIF="pppoe0"
DBG=0
ADDR="204.29.161.33"
PORT=50003
def sendheartbeatmsg(tosend):
iam=socket.gethostname()
sock=socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
msgboot="msg=%s;service=%s;name=%s" % (tosend, "selfcheck", iam)
sock.sendto(msgboot, (ADDR, PORT))
time.sleep(1)
sock.close()
class Pppoe:
def __init__(self, interface):
DBG=0
self.interface=interface
self.foundauthinfo=0
fh=os.popen("/sbin/pppoectl %s" % self.interface,"r")
while 1:
l=fh.readline()
if len(l) == 0:
fh.close()
break
if DBG: print l[:-1]
r=string.split(l[:-1])
if DBG: print r
if r[0] == self.interface+':':
s=string.split(r[1],'=')
self.phase=s[1]
elif r[0][:11] == 'myauthproto':
self.myauthproto=r[0][12:]
self.myauthname=r[1][11:]
self.foundauthinfo=1
elif r[0] == 'lcp' and r[1] == 'timeout:':
self.lcptimeount=r[2]
elif r[0] == 'idle' and r[1] == 'timeout' and r[2] == "=":
self.idletimeout=r[3]
elif r[0] == 'max-auth-failure' and r[1] == '=':
self.maxauthfailure=r[2]
elif r[0] == 'max-noreceive' and r[1] == '=':
self.maxnoreceive=string.join(r[2:], ' ')
elif r[0] == 'max-alive-missed' and r[1] == '=':
self.maxalivemissed = string.join(r[2:], ' ')
fh=os.popen("/sbin/pppoectl -d %s" % self.interface,"r")
while 1:
l=fh.readline()
if len(l) == 0:
fh.close()
break
if DBG: print l[:-1]
r=string.split(l[:-1])
if DBG: print r
if r[0] == self.interface+':':
self.state=string.join(r[3:],' ')
elif r[0] == 'Session' and r[1] == 'ID:':
self.sessionid=r[2]
elif r[0] == 'PADI' and r[1] == 'retries:':
self.PADIretries=r[2]
elif r[0] == 'PADR' and r[1] == 'retries:':
self.PADRretries=r[2]
fh=os.popen("/sbin/ifconfig %s" % self.interface,"r")
if DBG: print fh
self.foundinet=0
while 1:
l=fh.readline()
if len(l) == 0:
fh.close()
break
if DBG: print l[:-1]
r=string.split(l[:-1])
if DBG: print r
if r[0] == self.interface+':':
s=string.split(r[1],'=')
s1=string.split(s[1],'<')
self.flagshex=s1[0]
self.flags=string.split(s1[1][:-1],',')
self.mtu=r[3]
elif r[0] == 'inet':
self.foundinet=1
self.ipaddr=r[1]
self.ipgw=r[3]
self.ipmask=r[5]
def __repr__(self):
r=[]
keys=self.__dict__.keys()
keys.sort()
for k in keys:
r.append("%-14s: %s" % (k, self.__dict__[k]))
return string.join(r,'\n')
def notee():
if not self.foundauthinfo:
print"<B>PROBLEM: no login info configured, use \n'pppoectl %s myauthproto=pap myauthname=\"<account>\" myauthsecret=\"<password>\"'</B>"
return
def checkall(IF):
Res="The interface is"
if "UP" in IF.flags:
Res+=" up"
else:
Res=".\n<B>PROBLEM: the interface is down, use 'ifconfig %s up'</B>" % (PPPIF)
return(Res)
if IF.foundauthinfo:
Res+=", has authentication information"
else:
Res+=".\n<B>PROBLEM: pppoe has no authentication information.</B>"
return(Res)
if IF.foundinet:
Res+=", is configured"
else:
Res+=".\n<B>PROBLEM: %s is not configured, use 'ifconfig %s inet 0.0.0.0 0.0.0.1'</B>" % (PPPIF, PPPIF)
return(Res)
if IF.ipaddr != '0.0.0.0':
Res+=", has an IP address"
else:
Res+=".\n<B>PROBLEM: The inteface has no address</B>"
return(Res)
if IF.ipgw != '0.0.0.1':
Res+=", has an IP gateway"
else:
Res+=".\n<B>PROBLEM: The interfaces has no gateway</B>"
return(Res)
# ec=os.system("/sbin/ping -n -c 1 %s >/dev/null 2>&1" % "217.237.157.246")
ec=os.system("/sbin/ping -n -c 1 -w 2 %s >/dev/null 2>&1" % IF.ipgw)
if ec == 0:
Res+=".\nThe gateway is reachable."
else:
Res+=".\n<B>PROBLEM: The gateway is not reachable.</B>"
return(Res)
Res+=".\n\n<B> All appears to be well.</B>"
return(Res)
#
# Main
#
uname=os.uname()
lines=[]
if sys.stdin.isatty():
uri='/selfcheck'
else:
while 1:
l2=sys.stdin.readline()
# print "<pre>[",len(l2),l2[:-2],"]</pre>"
sys.stdout.flush()
if l2[:-2] == "":
break
lines.append(l2[:-2])
uri=string.split(lines[0],' ')[1]
if uri != '/favicon.ico':
sendheartbeatmsg(uri)
date=time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime())
if uri != "/selfcheck":
print "HTTP/1.1 404 Not Found"
print "Date: %s" % date
print "Connection: close"
print "Content-Type: text/html; charset=iso-8859-1"
print ""
print "Nothing here, move along.."
sys.exit(0)
print """HTTP/1.1 200 OK
Date: %s
Server: Selfcheck/1.0 (Python)
Last-Modified: %s
Accept-Ranges: bytes
Connection: close
Content-Type: text/html; charset=ISO-8859-1""" % (date, date)
print """
<html>
<head>
<title>ADSL Check</title>
</head>
<body>
<H2>ADSL Check</H2>
<pre>"""
print "Current Time: %s" % date
print "Machine: %s" % uname[1]
print "OS: %s" % uname[0]+"/"+uname[4]+" "+uname[2]
print "Uptime: ",
sys.stdout.flush()
os.system("uptime")
print "<br>"
print "<B>Checking interface %s</B>" % PPPIF
IF=Pppoe(PPPIF)
print checkall(IF)
print "<br>"
print "<B>Additional information</B>"
print "<BR><B>Users</B>"
sys.stdout.flush()
os.system("w")
print "<br>"
print "<B>Interface data</B>"
print IF
print
print "<br>"
if 0:
print "<B>Ping</B>"
sys.stdout.flush()
ec=os.system("/sbin/ping -n -q -c 3 204.29.161.37")
if ec != 0:
print "<B> ping failed!</B>"
else:
print "<B>Traceroute</B>"
sys.stdout.flush()
# ec=os.system("/usr/sbin/traceroute -w 2 204.29.161.37")
ec=os.system("/usr/pkg/sbin/mtr -r -c 2 204.29.161.37")
if ec != 0:
print "<B> traceroute failed!</B>"
print "<br>"
print "<B>Routing Table</B>"
sys.stdout.flush()
os.system("/usr/bin/netstat -rnLfinet")
print "<br>"
print "<B>Relevant log entries</B>"
sys.stdout.flush()
os.system("grep %s: /var/log/messages" % IF.interface)
if DBG:
for l in lines:
print l
print "</pre>"
print "All done"
print "</body>"
print "</html>"
+1 -18
View File
@@ -1,20 +1,3 @@
<input type="checkbox" id="drawer-toggle" name="drawer-toggle"/>
<label for="drawer-toggle" id="drawer-toggle-label"></label>
<header>{{ header }}</header>
<nav id="drawer">
<ul style="padding: 0;">
<!-- <li><a href="/pr/cam">Camera</a></li>
<li><a href="/pr/show">Motion</a></li> -->
<li><a href="/pr/mlog">Motion Log</a></li>
<li><a href="/pr/weather">Weather</a></li>
<!-- <li><a href="/pr/callers">Callers</a></li>
<li><a href="/pr/209103weather">209 Weather</a></li>
<li><a href="/pr/209103heating">209 Heating</a></li>a -->
<li><a href="/pr/famfind">Family Finder</a></li>
<li><a href="/pr/acheck">ACheck</a></li>
<li><a href="/pr/heartbeat">Heartbeat</a></li>
{{ if }}
<li><a href="/pr/ups">UPS</a></li>
<li><a href="/pr/test">Test</a></li>
</ul>
</nav>
-22
View File
@@ -1,22 +0,0 @@
import asyncio
import websockets
async def hello():
uri = "ws://localhost:50005/messages"
async with websockets.connect(uri) as websocket:
name = "Andreas"
await websocket.send(name)
print(f"> {name}")
while True:
greeting = await websocket.recv()
print(f"< {greeting}")
if greeting == "bye":
break
print("out of here")
asyncio.get_event_loop().run_until_complete(hello())