refactor and rewrite for asyncio
This commit is contained in:
@@ -6,3 +6,6 @@ __pycache__/
|
||||
.flake8
|
||||
.venv/
|
||||
test/
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)")
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
+157
-198
@@ -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):
|
||||
async def index(request):
|
||||
res = []
|
||||
res.append('<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">')
|
||||
res.append("<html>")
|
||||
res.append("<head>")
|
||||
res.append(f"<title>Heartbeat</title>")
|
||||
if tcss:
|
||||
res.append(tcss)
|
||||
res.append("</head>")
|
||||
res.append('<body BGCOLOR = "#FFFFFF" LINK = "#008000" VLINK = "#008000">')
|
||||
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>")
|
||||
body = "\n".join(res)
|
||||
return web.Response(text=body, content_type="text/html")
|
||||
|
||||
server_version = f"HeartbeatHTTP/{VER}"
|
||||
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 version_string(self):
|
||||
return self.server_version
|
||||
async def api_messages(request):
|
||||
lst = msgs_getter()[-30:]
|
||||
return web.json_response(lst)
|
||||
|
||||
def handle(self):
|
||||
async def cmd(request):
|
||||
qa = request.rel_url.query
|
||||
uname = qa.get("h")
|
||||
ucmd = qa.get("c")
|
||||
if not ucmd or not uname:
|
||||
return web.Response(status=400, text="need h= and c= arguments")
|
||||
if uname not in hbdclass.Host.hosts:
|
||||
return web.Response(status=400, text=f"h={uname} not found")
|
||||
hbdclass.Host.hosts[uname].cmds.append(("CMD", {"cmd": urllib.parse.unquote(ucmd)}))
|
||||
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]
|
||||
return web.Response(text="Done")
|
||||
|
||||
async def register(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")
|
||||
ll = hbdclass.Host.hosts[uname].registerDns()
|
||||
if log:
|
||||
log(uname, ll)
|
||||
return web.Response(text=str(ll))
|
||||
|
||||
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:
|
||||
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 = [n for n in hbdclass.Host.hosts if hbdclass.Host.hosts[n].cver >= 2]
|
||||
out = []
|
||||
for n in names:
|
||||
err = None
|
||||
try:
|
||||
return server.BaseHTTPRequestHandler.handle(self)
|
||||
r = {"csum": None, "code": ucode}
|
||||
hbdclass.Host.hosts[n].cmds.append(("UPD", r))
|
||||
except Exception as e:
|
||||
self.log_error("Request went away: %r", e)
|
||||
self.close_connection = 1
|
||||
return
|
||||
err = str(e)
|
||||
out.append(f"update started for {n}: {err if err else 'OK'}")
|
||||
return web.Response(text="\n".join(out))
|
||||
|
||||
def do_HEAD(self):
|
||||
self.setheaders(200)
|
||||
async def restart(request):
|
||||
# signal main application to perform restart if needed
|
||||
# not implemented here - return OK
|
||||
if log:
|
||||
log(None, "restart request")
|
||||
return web.Response(text="restart request")
|
||||
|
||||
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()
|
||||
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 = 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:],
|
||||
)
|
||||
return web.Response(text=body, content_type="text/html")
|
||||
|
||||
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
|
||||
async def static(request):
|
||||
"""Serve files from the package static directory.
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
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
|
||||
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),
|
||||
]
|
||||
)
|
||||
|
||||
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)
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, host, port)
|
||||
await site.start()
|
||||
|
||||
if qr.path == "/":
|
||||
res = self.buildpage()
|
||||
if verbose:
|
||||
print(f"HTTP server started on {host}:{port}")
|
||||
|
||||
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))
|
||||
try:
|
||||
await asyncio.Future()
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
|
||||
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 live view with websockets
|
||||
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,
|
||||
},
|
||||
)
|
||||
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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
+242
-48
@@ -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
|
||||
|
||||
logf = None
|
||||
lastfm = ["", "", ""]
|
||||
|
||||
# shared runtime collections and helpers
|
||||
msgs = []
|
||||
|
||||
def initlog(logfile):
|
||||
try:
|
||||
return open(logfile, "a+")
|
||||
except Exception as e:
|
||||
import sys
|
||||
print("cannot open loffile %s, using STDERR: %s" % (logfile, e))
|
||||
return sys.stderr
|
||||
|
||||
def log(host, m, service=None):
|
||||
ts = time.time()
|
||||
s = f"{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(ts))} {host or ''} {m}"
|
||||
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()
|
||||
|
||||
# shared runtime collections and helpers
|
||||
msgs = []
|
||||
# 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
|
||||
import time
|
||||
import hbdclass
|
||||
from . 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
|
||||
from . import monitor as monitor_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))
|
||||
@@ -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,32 +106,37 @@ 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(
|
||||
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="",
|
||||
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,
|
||||
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 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 |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,23 +76,36 @@ 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
|
||||
for srv in servers:
|
||||
await srv
|
||||
try:
|
||||
for srv in servers:
|
||||
await srv
|
||||
|
||||
if _verbose:
|
||||
logger.info("WebSocket server started on port %s (wss %s)", ws_port, wss_port)
|
||||
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()
|
||||
# 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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -1,188 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# pidlockfile.py
|
||||
#
|
||||
# Copyright © 2008–2009 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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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> " % 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, ' </td><td>')
|
||||
i += 1
|
||||
print '</table></pre>'
|
||||
|
||||
execfile("/home/andreas/cgi-bin/trailer.py")
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -1,2 +0,0 @@
|
||||
websockets>=13.2
|
||||
mattermostdriver>=7.3.0
|
||||
@@ -0,0 +1,4 @@
|
||||
key "rndc-key" {
|
||||
algorithm hmac-md5;
|
||||
secret "qlGa+AYKtyOgWNuozqECMw==";
|
||||
};
|
||||
@@ -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 $@
|
||||
|
||||
Executable
+12
@@ -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
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user