refactor and rewrite for asyncio

This commit is contained in:
2026-02-06 12:34:59 -05:00
parent 700ea8d6a4
commit 4df700e4ef
54 changed files with 772 additions and 4334 deletions
+157 -198
View File
@@ -1,20 +1,23 @@
"""HTTP server and handler scaffolds (thin wrappers around http.server)."""
from http import server
"""HTTP server implementation using aiohttp and jinja2."""
import asyncio
import json
import time
import urllib.parse
from urllib3 import request
import os
import logging
from aiohttp import web
from fastapi.templating import Jinja2Templates
import jinja2
class HttpServer(server.ThreadingHTTPServer):
allow_reuse_address = True
logger = logging.getLogger(__name__)
def threaded(self):
pass
def _render_template(html_str: str, **context) -> str:
tmpl = jinja2.Template(html_str)
return tmpl.render(**context)
def make_handler_class(
async def start(
host: str,
port: int,
config,
hbdclass,
msgs_getter,
@@ -28,209 +31,165 @@ def make_handler_class(
get_now=None,
VER="",
):
"""Return a BaseHTTPRequestHandler subclass bound to runtime objects.
"""Start an aiohttp web server and block until cancelled.
`msgs_getter` should be a callable that returns a list-like of messages.
This function is intended to be awaited inside the main asyncio event loop.
"""
templates = Jinja2Templates(directory="templates")
get_now = get_now or (lambda: time.time())
class CustomHandler(server.BaseHTTPRequestHandler):
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