refactor
This commit is contained in:
@@ -0,0 +1,21 @@
|
|||||||
|
#name: "w02"
|
||||||
|
hb_port: 50003
|
||||||
|
hbd_host: ''
|
||||||
|
#logfile: "/home/andreas/public_html/messages/andreas"
|
||||||
|
logfile: "/Users/andreas/public_html/messages/andreas"
|
||||||
|
logfmt: "msg"
|
||||||
|
grace: 40
|
||||||
|
interval: 10
|
||||||
|
watchhosts:
|
||||||
|
# "localhost":
|
||||||
|
# "haschloss" :
|
||||||
|
# "cotgate":
|
||||||
|
# "wentworth":
|
||||||
|
"winter":
|
||||||
|
notify: +14168226179
|
||||||
|
src: "signal"
|
||||||
|
dyndnshosts: {"haschloss", "wayback", "wertvoll", "weekend", "cotgate", "rvgate", "draper", "eris"}
|
||||||
|
drophosts: {"unknown", "wookie15", "wort"}
|
||||||
|
nsupdate_bin: "/usr/local/bin/nsupdate"
|
||||||
|
pushsrv: "pushover"
|
||||||
|
dyndomains: {"wrede.org"}
|
||||||
Vendored
+39
-15
@@ -1,17 +1,41 @@
|
|||||||
{
|
{
|
||||||
// Use IntelliSense to learn about possible attributes.
|
// Use IntelliSense to learn about possible attributes.
|
||||||
// Hover to view descriptions of existing attributes.
|
// Hover to view descriptions of existing attributes.
|
||||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"name": "Python: Current File",
|
"name": "Python: Run hbd (module)",
|
||||||
"type": "python",
|
"type": "debugpy",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"program": "hbd",
|
"module": "hbd.cli",
|
||||||
"console": "integratedTerminal",
|
"args": ["-c", ".hb.yaml", "-f", "-v", "-x", "-x", "-x"],
|
||||||
"args": ["-f"]
|
"cwd": "${workspaceFolder}",
|
||||||
|
"env": {
|
||||||
}
|
"PYTHONPATH": "${workspaceFolder}"
|
||||||
]
|
},
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"justMyCode": false,
|
||||||
|
"subProcess": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Python: Attach (localhost:5678)",
|
||||||
|
"type": "debugpy",
|
||||||
|
"request": "attach",
|
||||||
|
"connect": { "host": "localhost", "port": 5678 },
|
||||||
|
"pathMappings": [
|
||||||
|
{ "localRoot": "${workspaceFolder}", "remoteRoot": "${workspaceFolder}" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Python: Run hbd with debugpy (listen)",
|
||||||
|
"type": "debugpy",
|
||||||
|
"request": "launch",
|
||||||
|
"module": "debugpy",
|
||||||
|
"args": ["--listen", "5678", "--wait-for-client", "-m", "hbd.cli", "-c", ".hb.yaml", "-f", "-v"],
|
||||||
|
"env": { "PYTHONPATH": "${workspaceFolder}" },
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"justMyCode": false
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,174 @@
|
|||||||
|
|
||||||
|
|
||||||
To obtain a DNS verified certificate for the websockert server:
|
# Heartbeat Daemon (hbd) ✅
|
||||||
|
|
||||||
certbot certonly -d w02.wrede.ca -d ws.wrede.ca --dns-rfc2136 --dns-rfc2136-credentials /usr/local/etc/letsencrypt/certbot_dns_rfc2136.ini --dns-rfc2136-propagation-seconds 10
|
A lightweight daemon that listens for UDP heartbeat messages and acts on them: keeps host state, optionally updates DNS records via `nsupdate`, forwards messages to WebSocket clients, and sends notifications (email, Pushover, Mattermost, Signal). It is a refactor of a previously monolithic script into a modular Python package (`hbd`).
|
||||||
|
|
||||||
and the rfc2136.ini file looks like:
|
---
|
||||||
|
|
||||||
# Target DNS server
|
## 📌 Features
|
||||||
dns_rfc2136_server = 192.168.196.248
|
|
||||||
# Target DNS port
|
- Receive and parse heartbeat datagrams (text or zlib-compressed) ✅
|
||||||
dns_rfc2136_port = 53
|
- Maintain host state and detect up/down transitions ✅
|
||||||
# TSIG key name
|
- Queue DNS updates via `nsupdate` and run them in a background thread ✅
|
||||||
dns_rfc2136_name = tsig-key
|
- WebSocket API for live updates (hosts & messages) ✅
|
||||||
# TSIG key secret
|
- Notification pipeline (email, Pushover, Mattermost, Signal) ✅
|
||||||
dns_rfc2136_secret = 1KsWP8ZkZxBDKS0RQ2n3bkz1xpVPtz3Tk1y3r/dF+4knwGBzscse8iewaEr/6jUtxaL1taGME6eqSDtV2SD8NQ==
|
- Modular codebase suitable for unit testing and CI ✅
|
||||||
# TSIG key algorithm
|
|
||||||
dns_rfc2136_algorithm = HMAC-SHA512
|
---
|
||||||
|
|
||||||
|
## ⚙️ Quickstart
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- Python 3.10+ (project uses language features from recent Python)
|
||||||
|
- `nsupdate` (for DNS updates) if using dynamic DNS
|
||||||
|
|
||||||
|
Install dependencies (recommended into a venv):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install -r requirements.txt
|
||||||
|
# for development/testing tools
|
||||||
|
python -m pip install -r requirements-dev.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the daemon (example):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# run with default config lookup (~/.hb.yaml)
|
||||||
|
PYTHONPATH=. hbd -c .hb.yaml -f -v
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also run it directly via the package entrypoint after installation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m hbd.cli -c /path/to/config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐞 Debugging in VS Code
|
||||||
|
|
||||||
|
This repository includes a ready-to-use `.vscode/launch.json` with configurations to run or attach the VS Code debugger to `hbd`.
|
||||||
|
|
||||||
|
- Ensure the **Python** extension is installed and select the project `.venv` as the interpreter (bottom-left of VS Code).
|
||||||
|
- Use **F5** and pick one of these configurations from the Run view:
|
||||||
|
- **Python: Run hbd (module)** — runs `hbd.cli` as a module and sets `PYTHONPATH` to the workspace root (recommended).
|
||||||
|
- **Python: Run hbd with debugpy (listen)** — launches `debugpy` and `hbd` together; useful when you want the process to listen for a debugger.
|
||||||
|
- **Python: Attach (localhost:5678)** — attach the debugger to a running process started with `debugpy`.
|
||||||
|
|
||||||
|
To start `hbd` manually and wait for the debugger to attach, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=. python -m debugpy --listen 5678 --wait-for-client -m hbd.cli -c .hb.yaml -f -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Set breakpoints in modules such as `hbd/udp.py`, `hbd/dns.py`, or `hbd/server.py`, and use the **Attach** configuration to connect. Use `justMyCode: false` if you need to step into third-party code.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠 Configuration
|
||||||
|
|
||||||
|
`hbd` reads YAML configuration (optional). If `PyYAML` is not installed, built-in defaults are used. Example configuration keys (see `hbd/config.py`):
|
||||||
|
|
||||||
|
- `hb_port`: UDP port to listen for heartbeats (default: 50003)
|
||||||
|
- `hbd_port`: internal control port (default: 50004)
|
||||||
|
- `hbd_host`: bind address for HTTP/WSS
|
||||||
|
- `pickfile`: path for persisted state
|
||||||
|
- `logfile`: path to log file
|
||||||
|
- `logfmt`: `text` or `msg`
|
||||||
|
- `pushsrv`: push service (`pushover`|`mattermost`|`all`)
|
||||||
|
- `interval` / `grace`: heartbeat timing configuration
|
||||||
|
- `dyndomains`: list of dyndomains to update via `nsupdate`
|
||||||
|
- `nsupdate_bin`: path to nsupdate binary
|
||||||
|
|
||||||
|
Example `.hb.yaml` (minimal):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
hbd_host: 0.0.0.0
|
||||||
|
hbd_port: 50004
|
||||||
|
dyndomains:
|
||||||
|
- example.com
|
||||||
|
nsupdate_bin: /usr/bin/nsupdate
|
||||||
|
pushsrv: pushover
|
||||||
|
```
|
||||||
|
|
||||||
|
> Tip: `config.DEFAULTS` in `hbd/config.py` contains the canonical defaults and accepted configuration keys.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Architecture & Modules
|
||||||
|
|
||||||
|
- `hbd.proto` — serialization/deserialization of heartbeat messages (supports compressed payloads)
|
||||||
|
- `hbd.udp` — UDP parsing and `handle_datagram` implementation (main state machine)
|
||||||
|
- `hbd.dns` — `create_nsupdate_payload`, `nsupdate`, and a background DNS thread (`start_dns_thread`)
|
||||||
|
- `hbd.notify` — email and push notification helpers
|
||||||
|
- `hbd.ws` — WebSocket server and thread-safe broadcast helpers
|
||||||
|
- `hbd.http` — HTTP handler factory for the status UI/API
|
||||||
|
- `hbd.utils` — small utility helpers (`shortname`, `dur`, `initlog`)
|
||||||
|
- `hbd.cli` — CLI entrypoint and argument parsing
|
||||||
|
- `hbd.server` — async orchestration to run UDP/HTTP/WSS components
|
||||||
|
|
||||||
|
This modular layout makes the code easier to test and maintain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Testing & Dev
|
||||||
|
|
||||||
|
Tests are implemented using `unittest` and additional tests rely on `pytest` if you prefer. To run tests locally without installing anything beyond the dev requirements:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# with project root on PYTHONPATH
|
||||||
|
PYTHONPATH=. python -m unittest discover -v
|
||||||
|
# or with pytest if installed
|
||||||
|
pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Developer tooling included:
|
||||||
|
- `pyproject.toml` — project metadata and dependencies
|
||||||
|
- `requirements-dev.txt` — dev/test dependencies
|
||||||
|
- `tox.ini` — convenience wrappers for running tests, lint, and mypy
|
||||||
|
|
||||||
|
To run linters and type checks locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# after installing dev deps
|
||||||
|
tox -e lint
|
||||||
|
tox -e mypy
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Running in production
|
||||||
|
|
||||||
|
- Use your system service manager (systemd, launchd, etc.) to run `hbd` in the background.
|
||||||
|
- Ensure `nsupdate` and necessary credentials are available for dynamic DNS updates.
|
||||||
|
- Configure TLS for WSS if you enable secure websockets.
|
||||||
|
|
||||||
|
> Note: The project contains a small example for obtaining DNS-verified certs (certbot with RFC2136) — see earlier commit history or ask me to re-add the example to this README if you want it documented here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
Contributions welcome! Please:
|
||||||
|
1. Open an issue to discuss larger changes.
|
||||||
|
2. Create a topic branch and a clear PR.
|
||||||
|
3. Add tests for new features and run linters.
|
||||||
|
4. Keep changes focused and documented.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📜 License
|
||||||
|
|
||||||
|
This repository is licensed under the MIT license. See `LICENSE` for details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
If you'd like, I can also:
|
||||||
|
- add a **GitHub Actions** workflow that runs tests and lint on push/PR 🔁
|
||||||
|
- add a `CONTRIBUTING.md` template for PRs and code style 💬
|
||||||
|
|
||||||
|
Which one should I do next? ✨
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""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()
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""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)
|
||||||
|
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""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)
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""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)")
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""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,49 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# daemon/__init__.py
|
|
||||||
# Part of ‘python-daemon’, an implementation of PEP 3143.
|
|
||||||
#
|
|
||||||
# Copyright © 2009–2015 Ben Finney <ben+python@benfinney.id.au>
|
|
||||||
# Copyright © 2006 Robert Niederreiter
|
|
||||||
#
|
|
||||||
# This is free software: you may copy, modify, and/or distribute this work
|
|
||||||
# under the terms of the Apache License, version 2.0 as published by the
|
|
||||||
# Apache Software Foundation.
|
|
||||||
# No warranty expressed or implied. See the file ‘LICENSE.ASF-2’ for details.
|
|
||||||
|
|
||||||
""" Library to implement a well-behaved Unix daemon process.
|
|
||||||
|
|
||||||
This library implements the well-behaved daemon specification of
|
|
||||||
:pep:`3143`, “Standard daemon process library”.
|
|
||||||
|
|
||||||
A well-behaved Unix daemon process is tricky to get right, but the
|
|
||||||
required steps are much the same for every daemon program. A
|
|
||||||
`DaemonContext` instance holds the behaviour and configured
|
|
||||||
process environment for the program; use the instance as a context
|
|
||||||
manager to enter a daemon state.
|
|
||||||
|
|
||||||
Simple example of usage::
|
|
||||||
|
|
||||||
import daemon
|
|
||||||
|
|
||||||
from spam import do_main_program
|
|
||||||
|
|
||||||
with daemon.DaemonContext():
|
|
||||||
do_main_program()
|
|
||||||
|
|
||||||
Customisation of the steps to become a daemon is available by
|
|
||||||
setting options on the `DaemonContext` instance; see the
|
|
||||||
documentation for that class for each option.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import absolute_import, unicode_literals
|
|
||||||
|
|
||||||
from .daemon import DaemonContext
|
|
||||||
|
|
||||||
|
|
||||||
# Local variables:
|
|
||||||
# coding: utf-8
|
|
||||||
# mode: python
|
|
||||||
# End:
|
|
||||||
# vim: fileencoding=utf-8 filetype=python :
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# daemon/_metadata.py
|
|
||||||
# Part of ‘python-daemon’, an implementation of PEP 3143.
|
|
||||||
#
|
|
||||||
# Copyright © 2008–2015 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 Apache License, version 2.0 as published by the
|
|
||||||
# Apache Software Foundation.
|
|
||||||
# No warranty expressed or implied. See the file ‘LICENSE.ASF-2’ for details.
|
|
||||||
|
|
||||||
""" Package metadata for the ‘python-daemon’ distribution. """
|
|
||||||
|
|
||||||
from __future__ import absolute_import, unicode_literals
|
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import collections
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
import pkg_resources
|
|
||||||
|
|
||||||
|
|
||||||
distribution_name = "python-daemon"
|
|
||||||
version_info_filename = "version_info.json"
|
|
||||||
|
|
||||||
|
|
||||||
def get_distribution_version_info(filename=version_info_filename):
|
|
||||||
""" Get the version info from the installed distribution.
|
|
||||||
|
|
||||||
:param filename: Base filename of the version info resource.
|
|
||||||
:return: The version info as a mapping of fields. If the
|
|
||||||
distribution is not available, the mapping is empty.
|
|
||||||
|
|
||||||
The version info is stored as a metadata file in the
|
|
||||||
distribution.
|
|
||||||
|
|
||||||
"""
|
|
||||||
version_info = {
|
|
||||||
"release_date": "UNKNOWN",
|
|
||||||
"version": "UNKNOWN",
|
|
||||||
"maintainer": "UNKNOWN",
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
distribution = pkg_resources.get_distribution(distribution_name)
|
|
||||||
except pkg_resources.DistributionNotFound:
|
|
||||||
distribution = None
|
|
||||||
|
|
||||||
if distribution is not None:
|
|
||||||
if distribution.has_metadata(version_info_filename):
|
|
||||||
content = distribution.get_metadata(version_info_filename)
|
|
||||||
version_info = json.loads(content)
|
|
||||||
|
|
||||||
return version_info
|
|
||||||
|
|
||||||
|
|
||||||
version_info = get_distribution_version_info()
|
|
||||||
|
|
||||||
version_installed = version_info["version"]
|
|
||||||
|
|
||||||
|
|
||||||
rfc822_person_regex = re.compile("^(?P<name>[^<]+) <(?P<email>[^>]+)>$")
|
|
||||||
|
|
||||||
ParsedPerson = collections.namedtuple("ParsedPerson", ["name", "email"])
|
|
||||||
|
|
||||||
|
|
||||||
def parse_person_field(value):
|
|
||||||
""" Parse a person field into name and email address.
|
|
||||||
|
|
||||||
:param value: The text value specifying a person.
|
|
||||||
:return: A 2-tuple (name, email) for the person's details.
|
|
||||||
|
|
||||||
If the `value` does not match a standard person with email
|
|
||||||
address, the `email` item is ``None``.
|
|
||||||
|
|
||||||
"""
|
|
||||||
result = (None, None)
|
|
||||||
|
|
||||||
match = rfc822_person_regex.match(value)
|
|
||||||
if len(value):
|
|
||||||
if match is not None:
|
|
||||||
result = ParsedPerson(name=match.group("name"), email=match.group("email"))
|
|
||||||
else:
|
|
||||||
result = ParsedPerson(name=value, email=None)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
author_name = "Ben Finney"
|
|
||||||
author_email = "ben+python@benfinney.id.au"
|
|
||||||
author = "{name} <{email}>".format(name=author_name, email=author_email)
|
|
||||||
|
|
||||||
|
|
||||||
class YearRange:
|
|
||||||
""" A range of years spanning a period. """
|
|
||||||
|
|
||||||
def __init__(self, begin, end=None):
|
|
||||||
self.begin = begin
|
|
||||||
self.end = end
|
|
||||||
|
|
||||||
def __unicode__(self):
|
|
||||||
text = "{range.begin:04d}".format(range=self)
|
|
||||||
if self.end is not None:
|
|
||||||
if self.end > self.begin:
|
|
||||||
text = "{range.begin:04d}–{range.end:04d}".format(range=self)
|
|
||||||
return text
|
|
||||||
|
|
||||||
__str__ = __unicode__
|
|
||||||
|
|
||||||
|
|
||||||
def make_year_range(begin_year, end_date=None):
|
|
||||||
""" Construct the year range given a start and possible end date.
|
|
||||||
|
|
||||||
:param begin_date: The beginning year (text) for the range.
|
|
||||||
:param end_date: The end date (text, ISO-8601 format) for the
|
|
||||||
range, or a non-date token string.
|
|
||||||
:return: The range of years as a `YearRange` instance.
|
|
||||||
|
|
||||||
If the `end_date` is not a valid ISO-8601 date string, the
|
|
||||||
range has ``None`` for the end year.
|
|
||||||
|
|
||||||
"""
|
|
||||||
begin_year = int(begin_year)
|
|
||||||
|
|
||||||
try:
|
|
||||||
end_date = datetime.datetime.strptime(end_date, "%Y-%m-%d")
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
# Specified end_date value is not a valid date.
|
|
||||||
end_year = None
|
|
||||||
else:
|
|
||||||
end_year = end_date.year
|
|
||||||
|
|
||||||
year_range = YearRange(begin=begin_year, end=end_year)
|
|
||||||
|
|
||||||
return year_range
|
|
||||||
|
|
||||||
|
|
||||||
copyright_year_begin = "2001"
|
|
||||||
build_date = version_info["release_date"]
|
|
||||||
copyright_year_range = make_year_range(copyright_year_begin, build_date)
|
|
||||||
|
|
||||||
copyright = "Copyright © {year_range} {author} and others".format(
|
|
||||||
year_range=copyright_year_range, author=author
|
|
||||||
)
|
|
||||||
license = "Apache-2"
|
|
||||||
url = "https://alioth.debian.org/projects/python-daemon/"
|
|
||||||
|
|
||||||
|
|
||||||
# Local variables:
|
|
||||||
# coding: utf-8
|
|
||||||
# mode: python
|
|
||||||
# End:
|
|
||||||
# vim: fileencoding=utf-8 filetype=python :
|
|
||||||
@@ -1,940 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# daemon/daemon.py
|
|
||||||
# Part of ‘python-daemon’, an implementation of PEP 3143.
|
|
||||||
#
|
|
||||||
# Copyright © 2008–2015 Ben Finney <ben+python@benfinney.id.au>
|
|
||||||
# Copyright © 2007–2008 Robert Niederreiter, Jens Klein
|
|
||||||
# Copyright © 2004–2005 Chad J. Schroeder
|
|
||||||
# Copyright © 2003 Clark Evans
|
|
||||||
# Copyright © 2002 Noah Spurrier
|
|
||||||
# Copyright © 2001 Jürgen Hermann
|
|
||||||
#
|
|
||||||
# This is free software: you may copy, modify, and/or distribute this work
|
|
||||||
# under the terms of the Apache License, version 2.0 as published by the
|
|
||||||
# Apache Software Foundation.
|
|
||||||
# No warranty expressed or implied. See the file ‘LICENSE.ASF-2’ for details.
|
|
||||||
|
|
||||||
""" Daemon process behaviour.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import absolute_import, unicode_literals
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import resource
|
|
||||||
import errno
|
|
||||||
import signal
|
|
||||||
import socket
|
|
||||||
import atexit
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Python 2 has both ‘str’ (bytes) and ‘unicode’ (text).
|
|
||||||
basestring = basestring
|
|
||||||
unicode = unicode
|
|
||||||
except NameError:
|
|
||||||
# Python 3 names the Unicode data type ‘str’.
|
|
||||||
basestring = str
|
|
||||||
unicode = str
|
|
||||||
|
|
||||||
|
|
||||||
class DaemonError(Exception):
|
|
||||||
""" Base exception class for errors from this module. """
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
self._chain_from_context()
|
|
||||||
|
|
||||||
super(DaemonError, self).__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
def _chain_from_context(self):
|
|
||||||
_chain_exception_from_existing_exception_context(self, as_cause=True)
|
|
||||||
|
|
||||||
|
|
||||||
class DaemonOSEnvironmentError(DaemonError, OSError):
|
|
||||||
""" Exception raised when daemon OS environment setup receives error. """
|
|
||||||
|
|
||||||
|
|
||||||
class DaemonProcessDetachError(DaemonError, OSError):
|
|
||||||
""" Exception raised when process detach fails. """
|
|
||||||
|
|
||||||
|
|
||||||
class DaemonContext:
|
|
||||||
""" Context for turning the current program into a daemon process.
|
|
||||||
|
|
||||||
A `DaemonContext` instance represents the behaviour settings and
|
|
||||||
process context for the program when it becomes a daemon. The
|
|
||||||
behaviour and environment is customised by setting options on the
|
|
||||||
instance, before calling the `open` method.
|
|
||||||
|
|
||||||
Each option can be passed as a keyword argument to the `DaemonContext`
|
|
||||||
constructor, or subsequently altered by assigning to an attribute on
|
|
||||||
the instance at any time prior to calling `open`. That is, for
|
|
||||||
options named `wibble` and `wubble`, the following invocation::
|
|
||||||
|
|
||||||
foo = daemon.DaemonContext(wibble=bar, wubble=baz)
|
|
||||||
foo.open()
|
|
||||||
|
|
||||||
is equivalent to::
|
|
||||||
|
|
||||||
foo = daemon.DaemonContext()
|
|
||||||
foo.wibble = bar
|
|
||||||
foo.wubble = baz
|
|
||||||
foo.open()
|
|
||||||
|
|
||||||
The following options are defined.
|
|
||||||
|
|
||||||
`files_preserve`
|
|
||||||
:Default: ``None``
|
|
||||||
|
|
||||||
List of files that should *not* be closed when starting the
|
|
||||||
daemon. If ``None``, all open file descriptors will be closed.
|
|
||||||
|
|
||||||
Elements of the list are file descriptors (as returned by a file
|
|
||||||
object's `fileno()` method) or Python `file` objects. Each
|
|
||||||
specifies a file that is not to be closed during daemon start.
|
|
||||||
|
|
||||||
`chroot_directory`
|
|
||||||
:Default: ``None``
|
|
||||||
|
|
||||||
Full path to a directory to set as the effective root directory of
|
|
||||||
the process. If ``None``, specifies that the root directory is not
|
|
||||||
to be changed.
|
|
||||||
|
|
||||||
`working_directory`
|
|
||||||
:Default: ``'/'``
|
|
||||||
|
|
||||||
Full path of the working directory to which the process should
|
|
||||||
change on daemon start.
|
|
||||||
|
|
||||||
Since a filesystem cannot be unmounted if a process has its
|
|
||||||
current working directory on that filesystem, this should either
|
|
||||||
be left at default or set to a directory that is a sensible “home
|
|
||||||
directory” for the daemon while it is running.
|
|
||||||
|
|
||||||
`umask`
|
|
||||||
:Default: ``0``
|
|
||||||
|
|
||||||
File access creation mask (“umask”) to set for the process on
|
|
||||||
daemon start.
|
|
||||||
|
|
||||||
A daemon should not rely on the parent process's umask value,
|
|
||||||
which is beyond its control and may prevent creating a file with
|
|
||||||
the required access mode. So when the daemon context opens, the
|
|
||||||
umask is set to an explicit known value.
|
|
||||||
|
|
||||||
If the conventional value of 0 is too open, consider setting a
|
|
||||||
value such as 0o022, 0o027, 0o077, or another specific value.
|
|
||||||
Otherwise, ensure the daemon creates every file with an
|
|
||||||
explicit access mode for the purpose.
|
|
||||||
|
|
||||||
`pidfile`
|
|
||||||
:Default: ``None``
|
|
||||||
|
|
||||||
Context manager for a PID lock file. When the daemon context opens
|
|
||||||
and closes, it enters and exits the `pidfile` context manager.
|
|
||||||
|
|
||||||
`detach_process`
|
|
||||||
:Default: ``None``
|
|
||||||
|
|
||||||
If ``True``, detach the process context when opening the daemon
|
|
||||||
context; if ``False``, do not detach.
|
|
||||||
|
|
||||||
If unspecified (``None``) during initialisation of the instance,
|
|
||||||
this will be set to ``True`` by default, and ``False`` only if
|
|
||||||
detaching the process is determined to be redundant; for example,
|
|
||||||
in the case when the process was started by `init`, by `initd`, or
|
|
||||||
by `inetd`.
|
|
||||||
|
|
||||||
`signal_map`
|
|
||||||
:Default: system-dependent
|
|
||||||
|
|
||||||
Mapping from operating system signals to callback actions.
|
|
||||||
|
|
||||||
The mapping is used when the daemon context opens, and determines
|
|
||||||
the action for each signal's signal handler:
|
|
||||||
|
|
||||||
* A value of ``None`` will ignore the signal (by setting the
|
|
||||||
signal action to ``signal.SIG_IGN``).
|
|
||||||
|
|
||||||
* A string value will be used as the name of an attribute on the
|
|
||||||
``DaemonContext`` instance. The attribute's value will be used
|
|
||||||
as the action for the signal handler.
|
|
||||||
|
|
||||||
* Any other value will be used as the action for the
|
|
||||||
signal handler. See the ``signal.signal`` documentation
|
|
||||||
for details of the signal handler interface.
|
|
||||||
|
|
||||||
The default value depends on which signals are defined on the
|
|
||||||
running system. Each item from the list below whose signal is
|
|
||||||
actually defined in the ``signal`` module will appear in the
|
|
||||||
default map:
|
|
||||||
|
|
||||||
* ``signal.SIGTTIN``: ``None``
|
|
||||||
|
|
||||||
* ``signal.SIGTTOU``: ``None``
|
|
||||||
|
|
||||||
* ``signal.SIGTSTP``: ``None``
|
|
||||||
|
|
||||||
* ``signal.SIGTERM``: ``'terminate'``
|
|
||||||
|
|
||||||
Depending on how the program will interact with its child
|
|
||||||
processes, it may need to specify a signal map that
|
|
||||||
includes the ``signal.SIGCHLD`` signal (received when a
|
|
||||||
child process exits). See the specific operating system's
|
|
||||||
documentation for more detail on how to determine what
|
|
||||||
circumstances dictate the need for signal handlers.
|
|
||||||
|
|
||||||
`uid`
|
|
||||||
:Default: ``os.getuid()``
|
|
||||||
|
|
||||||
`gid`
|
|
||||||
:Default: ``os.getgid()``
|
|
||||||
|
|
||||||
The user ID (“UID”) value and group ID (“GID”) value to switch
|
|
||||||
the process to on daemon start.
|
|
||||||
|
|
||||||
The default values, the real UID and GID of the process, will
|
|
||||||
relinquish any effective privilege elevation inherited by the
|
|
||||||
process.
|
|
||||||
|
|
||||||
`prevent_core`
|
|
||||||
:Default: ``True``
|
|
||||||
|
|
||||||
If true, prevents the generation of core files, in order to avoid
|
|
||||||
leaking sensitive information from daemons run as `root`.
|
|
||||||
|
|
||||||
`stdin`
|
|
||||||
:Default: ``None``
|
|
||||||
|
|
||||||
`stdout`
|
|
||||||
:Default: ``None``
|
|
||||||
|
|
||||||
`stderr`
|
|
||||||
:Default: ``None``
|
|
||||||
|
|
||||||
Each of `stdin`, `stdout`, and `stderr` is a file-like object
|
|
||||||
which will be used as the new file for the standard I/O stream
|
|
||||||
`sys.stdin`, `sys.stdout`, and `sys.stderr` respectively. The file
|
|
||||||
should therefore be open, with a minimum of mode 'r' in the case
|
|
||||||
of `stdin`, and mimimum of mode 'w+' in the case of `stdout` and
|
|
||||||
`stderr`.
|
|
||||||
|
|
||||||
If the object has a `fileno()` method that returns a file
|
|
||||||
descriptor, the corresponding file will be excluded from being
|
|
||||||
closed during daemon start (that is, it will be treated as though
|
|
||||||
it were listed in `files_preserve`).
|
|
||||||
|
|
||||||
If ``None``, the corresponding system stream is re-bound to the
|
|
||||||
file named by `os.devnull`.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
__metaclass__ = type
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
chroot_directory=None,
|
|
||||||
working_directory="/",
|
|
||||||
umask=0,
|
|
||||||
uid=None,
|
|
||||||
gid=None,
|
|
||||||
prevent_core=True,
|
|
||||||
detach_process=None,
|
|
||||||
files_preserve=None,
|
|
||||||
pidfile=None,
|
|
||||||
stdin=None,
|
|
||||||
stdout=None,
|
|
||||||
stderr=None,
|
|
||||||
signal_map=None,
|
|
||||||
):
|
|
||||||
""" Set up a new instance. """
|
|
||||||
self.chroot_directory = chroot_directory
|
|
||||||
self.working_directory = working_directory
|
|
||||||
self.umask = umask
|
|
||||||
self.prevent_core = prevent_core
|
|
||||||
self.files_preserve = files_preserve
|
|
||||||
self.pidfile = pidfile
|
|
||||||
self.stdin = stdin
|
|
||||||
self.stdout = stdout
|
|
||||||
self.stderr = stderr
|
|
||||||
|
|
||||||
if uid is None:
|
|
||||||
uid = os.getuid()
|
|
||||||
self.uid = uid
|
|
||||||
if gid is None:
|
|
||||||
gid = os.getgid()
|
|
||||||
self.gid = gid
|
|
||||||
|
|
||||||
if detach_process is None:
|
|
||||||
detach_process = is_detach_process_context_required()
|
|
||||||
self.detach_process = detach_process
|
|
||||||
|
|
||||||
if signal_map is None:
|
|
||||||
signal_map = make_default_signal_map()
|
|
||||||
self.signal_map = signal_map
|
|
||||||
|
|
||||||
self._is_open = False
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_open(self):
|
|
||||||
""" ``True`` if the instance is currently open. """
|
|
||||||
return self._is_open
|
|
||||||
|
|
||||||
def open(self):
|
|
||||||
""" Become a daemon process.
|
|
||||||
|
|
||||||
:return: ``None``.
|
|
||||||
|
|
||||||
Open the daemon context, turning the current program into a daemon
|
|
||||||
process. This performs the following steps:
|
|
||||||
|
|
||||||
* If this instance's `is_open` property is true, return
|
|
||||||
immediately. This makes it safe to call `open` multiple times on
|
|
||||||
an instance.
|
|
||||||
|
|
||||||
* If the `prevent_core` attribute is true, set the resource limits
|
|
||||||
for the process to prevent any core dump from the process.
|
|
||||||
|
|
||||||
* If the `chroot_directory` attribute is not ``None``, set the
|
|
||||||
effective root directory of the process to that directory (via
|
|
||||||
`os.chroot`).
|
|
||||||
|
|
||||||
This allows running the daemon process inside a “chroot gaol”
|
|
||||||
as a means of limiting the system's exposure to rogue behaviour
|
|
||||||
by the process. Note that the specified directory needs to
|
|
||||||
already be set up for this purpose.
|
|
||||||
|
|
||||||
* Set the process UID and GID to the `uid` and `gid` attribute
|
|
||||||
values.
|
|
||||||
|
|
||||||
* Close all open file descriptors. This excludes those listed in
|
|
||||||
the `files_preserve` attribute, and those that correspond to the
|
|
||||||
`stdin`, `stdout`, or `stderr` attributes.
|
|
||||||
|
|
||||||
* Change current working directory to the path specified by the
|
|
||||||
`working_directory` attribute.
|
|
||||||
|
|
||||||
* Reset the file access creation mask to the value specified by
|
|
||||||
the `umask` attribute.
|
|
||||||
|
|
||||||
* If the `detach_process` option is true, detach the current
|
|
||||||
process into its own process group, and disassociate from any
|
|
||||||
controlling terminal.
|
|
||||||
|
|
||||||
* Set signal handlers as specified by the `signal_map` attribute.
|
|
||||||
|
|
||||||
* If any of the attributes `stdin`, `stdout`, `stderr` are not
|
|
||||||
``None``, bind the system streams `sys.stdin`, `sys.stdout`,
|
|
||||||
and/or `sys.stderr` to the files represented by the
|
|
||||||
corresponding attributes. Where the attribute has a file
|
|
||||||
descriptor, the descriptor is duplicated (instead of re-binding
|
|
||||||
the name).
|
|
||||||
|
|
||||||
* If the `pidfile` attribute is not ``None``, enter its context
|
|
||||||
manager.
|
|
||||||
|
|
||||||
* Mark this instance as open (for the purpose of future `open` and
|
|
||||||
`close` calls).
|
|
||||||
|
|
||||||
* Register the `close` method to be called during Python's exit
|
|
||||||
processing.
|
|
||||||
|
|
||||||
When the function returns, the running program is a daemon
|
|
||||||
process.
|
|
||||||
|
|
||||||
"""
|
|
||||||
if self.is_open:
|
|
||||||
return
|
|
||||||
|
|
||||||
if self.chroot_directory is not None:
|
|
||||||
change_root_directory(self.chroot_directory)
|
|
||||||
|
|
||||||
if self.prevent_core:
|
|
||||||
prevent_core_dump()
|
|
||||||
|
|
||||||
change_file_creation_mask(self.umask)
|
|
||||||
change_working_directory(self.working_directory)
|
|
||||||
change_process_owner(self.uid, self.gid)
|
|
||||||
|
|
||||||
if self.detach_process:
|
|
||||||
detach_process_context()
|
|
||||||
|
|
||||||
signal_handler_map = self._make_signal_handler_map()
|
|
||||||
set_signal_handlers(signal_handler_map)
|
|
||||||
|
|
||||||
exclude_fds = self._get_exclude_file_descriptors()
|
|
||||||
close_all_open_files(exclude=exclude_fds)
|
|
||||||
|
|
||||||
redirect_stream(sys.stdin, self.stdin)
|
|
||||||
redirect_stream(sys.stdout, self.stdout)
|
|
||||||
redirect_stream(sys.stderr, self.stderr)
|
|
||||||
|
|
||||||
if self.pidfile is not None:
|
|
||||||
self.pidfile.__enter__()
|
|
||||||
|
|
||||||
self._is_open = True
|
|
||||||
|
|
||||||
register_atexit_function(self.close)
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
""" Context manager entry point. """
|
|
||||||
self.open()
|
|
||||||
return self
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
""" Exit the daemon process context.
|
|
||||||
|
|
||||||
:return: ``None``.
|
|
||||||
|
|
||||||
Close the daemon context. This performs the following steps:
|
|
||||||
|
|
||||||
* If this instance's `is_open` property is false, return
|
|
||||||
immediately. This makes it safe to call `close` multiple times
|
|
||||||
on an instance.
|
|
||||||
|
|
||||||
* If the `pidfile` attribute is not ``None``, exit its context
|
|
||||||
manager.
|
|
||||||
|
|
||||||
* Mark this instance as closed (for the purpose of future `open`
|
|
||||||
and `close` calls).
|
|
||||||
|
|
||||||
"""
|
|
||||||
if not self.is_open:
|
|
||||||
return
|
|
||||||
|
|
||||||
if self.pidfile is not None:
|
|
||||||
# Follow the interface for telling a context manager to exit,
|
|
||||||
# <URL:http://docs.python.org/library/stdtypes.html#typecontextmanager>.
|
|
||||||
self.pidfile.__exit__(None, None, None)
|
|
||||||
|
|
||||||
self._is_open = False
|
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_value, traceback):
|
|
||||||
""" Context manager exit point. """
|
|
||||||
self.close()
|
|
||||||
|
|
||||||
def terminate(self, signal_number, stack_frame):
|
|
||||||
""" Signal handler for end-process signals.
|
|
||||||
|
|
||||||
:param signal_number: The OS signal number received.
|
|
||||||
:param stack_frame: The frame object at the point the
|
|
||||||
signal was received.
|
|
||||||
:return: ``None``.
|
|
||||||
|
|
||||||
Signal handler for the ``signal.SIGTERM`` signal. Performs the
|
|
||||||
following step:
|
|
||||||
|
|
||||||
* Raise a ``SystemExit`` exception explaining the signal.
|
|
||||||
|
|
||||||
"""
|
|
||||||
exception = SystemExit(
|
|
||||||
"Terminating on signal {signal_number!r}".format(
|
|
||||||
signal_number=signal_number
|
|
||||||
)
|
|
||||||
)
|
|
||||||
raise exception
|
|
||||||
|
|
||||||
def _get_exclude_file_descriptors(self):
|
|
||||||
""" Get the set of file descriptors to exclude closing.
|
|
||||||
|
|
||||||
:return: A set containing the file descriptors for the
|
|
||||||
files to be preserved.
|
|
||||||
|
|
||||||
The file descriptors to be preserved are those from the
|
|
||||||
items in `files_preserve`, and also each of `stdin`,
|
|
||||||
`stdout`, and `stderr`. For each item:
|
|
||||||
|
|
||||||
* If the item is ``None``, it is omitted from the return
|
|
||||||
set.
|
|
||||||
|
|
||||||
* If the item's ``fileno()`` method returns a value, that
|
|
||||||
value is in the return set.
|
|
||||||
|
|
||||||
* Otherwise, the item is in the return set verbatim.
|
|
||||||
|
|
||||||
"""
|
|
||||||
files_preserve = self.files_preserve
|
|
||||||
if files_preserve is None:
|
|
||||||
files_preserve = []
|
|
||||||
files_preserve.extend(
|
|
||||||
item
|
|
||||||
for item in [self.stdin, self.stdout, self.stderr]
|
|
||||||
if hasattr(item, "fileno")
|
|
||||||
)
|
|
||||||
|
|
||||||
exclude_descriptors = set()
|
|
||||||
for item in files_preserve:
|
|
||||||
if item is None:
|
|
||||||
continue
|
|
||||||
file_descriptor = _get_file_descriptor(item)
|
|
||||||
if file_descriptor is not None:
|
|
||||||
exclude_descriptors.add(file_descriptor)
|
|
||||||
else:
|
|
||||||
exclude_descriptors.add(item)
|
|
||||||
|
|
||||||
return exclude_descriptors
|
|
||||||
|
|
||||||
def _make_signal_handler(self, target):
|
|
||||||
""" Make the signal handler for a specified target object.
|
|
||||||
|
|
||||||
:param target: A specification of the target for the
|
|
||||||
handler; see below.
|
|
||||||
:return: The value for use by `signal.signal()`.
|
|
||||||
|
|
||||||
If `target` is ``None``, return ``signal.SIG_IGN``. If `target`
|
|
||||||
is a text string, return the attribute of this instance named
|
|
||||||
by that string. Otherwise, return `target` itself.
|
|
||||||
|
|
||||||
"""
|
|
||||||
if target is None:
|
|
||||||
result = signal.SIG_IGN
|
|
||||||
elif isinstance(target, basestring):
|
|
||||||
name = target
|
|
||||||
result = getattr(self, name)
|
|
||||||
else:
|
|
||||||
result = target
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _make_signal_handler_map(self):
|
|
||||||
""" Make the map from signals to handlers for this instance.
|
|
||||||
|
|
||||||
:return: The constructed signal map for this instance.
|
|
||||||
|
|
||||||
Construct a map from signal numbers to handlers for this
|
|
||||||
context instance, suitable for passing to
|
|
||||||
`set_signal_handlers`.
|
|
||||||
|
|
||||||
"""
|
|
||||||
signal_handler_map = dict(
|
|
||||||
(signal_number, self._make_signal_handler(target))
|
|
||||||
for (signal_number, target) in self.signal_map.items()
|
|
||||||
)
|
|
||||||
return signal_handler_map
|
|
||||||
|
|
||||||
|
|
||||||
def _get_file_descriptor(obj):
|
|
||||||
""" Get the file descriptor, if the object has one.
|
|
||||||
|
|
||||||
:param obj: The object expected to be a file-like object.
|
|
||||||
:return: The file descriptor iff the file supports it; otherwise
|
|
||||||
``None``.
|
|
||||||
|
|
||||||
The object may be a non-file object. It may also be a
|
|
||||||
file-like object with no support for a file descriptor. In
|
|
||||||
either case, return ``None``.
|
|
||||||
|
|
||||||
"""
|
|
||||||
file_descriptor = None
|
|
||||||
if hasattr(obj, "fileno"):
|
|
||||||
try:
|
|
||||||
file_descriptor = obj.fileno()
|
|
||||||
except ValueError:
|
|
||||||
# The item doesn't support a file descriptor.
|
|
||||||
pass
|
|
||||||
|
|
||||||
return file_descriptor
|
|
||||||
|
|
||||||
|
|
||||||
def change_working_directory(directory):
|
|
||||||
""" Change the working directory of this process.
|
|
||||||
|
|
||||||
:param directory: The target directory path.
|
|
||||||
:return: ``None``.
|
|
||||||
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
os.chdir(directory)
|
|
||||||
except Exception as exc:
|
|
||||||
error = DaemonOSEnvironmentError(
|
|
||||||
"Unable to change working directory ({exc})".format(exc=exc)
|
|
||||||
)
|
|
||||||
raise error
|
|
||||||
|
|
||||||
|
|
||||||
def change_root_directory(directory):
|
|
||||||
""" Change the root directory of this process.
|
|
||||||
|
|
||||||
:param directory: The target directory path.
|
|
||||||
:return: ``None``.
|
|
||||||
|
|
||||||
Set the current working directory, then the process root directory,
|
|
||||||
to the specified `directory`. Requires appropriate OS privileges
|
|
||||||
for this process.
|
|
||||||
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
os.chdir(directory)
|
|
||||||
os.chroot(directory)
|
|
||||||
except Exception as exc:
|
|
||||||
error = DaemonOSEnvironmentError(
|
|
||||||
"Unable to change root directory ({exc})".format(exc=exc)
|
|
||||||
)
|
|
||||||
raise error
|
|
||||||
|
|
||||||
|
|
||||||
def change_file_creation_mask(mask):
|
|
||||||
""" Change the file creation mask for this process.
|
|
||||||
|
|
||||||
:param mask: The numeric file creation mask to set.
|
|
||||||
:return: ``None``.
|
|
||||||
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
os.umask(mask)
|
|
||||||
except Exception as exc:
|
|
||||||
error = DaemonOSEnvironmentError(
|
|
||||||
"Unable to change file creation mask ({exc})".format(exc=exc)
|
|
||||||
)
|
|
||||||
raise error
|
|
||||||
|
|
||||||
|
|
||||||
def change_process_owner(uid, gid):
|
|
||||||
""" Change the owning UID and GID of this process.
|
|
||||||
|
|
||||||
:param uid: The target UID for the daemon process.
|
|
||||||
:param gid: The target GID for the daemon process.
|
|
||||||
:return: ``None``.
|
|
||||||
|
|
||||||
Set the GID then the UID of the process (in that order, to avoid
|
|
||||||
permission errors) to the specified `gid` and `uid` values.
|
|
||||||
Requires appropriate OS privileges for this process.
|
|
||||||
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
os.setgid(gid)
|
|
||||||
os.setuid(uid)
|
|
||||||
except Exception as exc:
|
|
||||||
error = DaemonOSEnvironmentError(
|
|
||||||
"Unable to change process owner ({exc})".format(exc=exc)
|
|
||||||
)
|
|
||||||
raise error
|
|
||||||
|
|
||||||
|
|
||||||
def prevent_core_dump():
|
|
||||||
""" Prevent this process from generating a core dump.
|
|
||||||
|
|
||||||
:return: ``None``.
|
|
||||||
|
|
||||||
Set the soft and hard limits for core dump size to zero. On Unix,
|
|
||||||
this entirely prevents the process from creating core dump.
|
|
||||||
|
|
||||||
"""
|
|
||||||
core_resource = resource.RLIMIT_CORE
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Ensure the resource limit exists on this platform, by requesting
|
|
||||||
# its current value.
|
|
||||||
core_limit_prev = resource.getrlimit(core_resource)
|
|
||||||
except ValueError as exc:
|
|
||||||
error = DaemonOSEnvironmentError(
|
|
||||||
"System does not support RLIMIT_CORE resource limit"
|
|
||||||
" ({exc})".format(exc=exc)
|
|
||||||
)
|
|
||||||
raise error
|
|
||||||
|
|
||||||
# Set hard and soft limits to zero, i.e. no core dump at all.
|
|
||||||
core_limit = (0, 0)
|
|
||||||
resource.setrlimit(core_resource, core_limit)
|
|
||||||
|
|
||||||
|
|
||||||
def detach_process_context():
|
|
||||||
""" Detach the process context from parent and session.
|
|
||||||
|
|
||||||
:return: ``None``.
|
|
||||||
|
|
||||||
Detach from the parent process and session group, allowing the
|
|
||||||
parent to exit while this process continues running.
|
|
||||||
|
|
||||||
Reference: “Advanced Programming in the Unix Environment”,
|
|
||||||
section 13.3, by W. Richard Stevens, published 1993 by
|
|
||||||
Addison-Wesley.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
def fork_then_exit_parent(error_message):
|
|
||||||
""" Fork a child process, then exit the parent process.
|
|
||||||
|
|
||||||
:param error_message: Message for the exception in case of a
|
|
||||||
detach failure.
|
|
||||||
:return: ``None``.
|
|
||||||
:raise DaemonProcessDetachError: If the fork fails.
|
|
||||||
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
pid = os.fork()
|
|
||||||
if pid > 0:
|
|
||||||
os._exit(0)
|
|
||||||
except OSError as exc:
|
|
||||||
error = DaemonProcessDetachError(
|
|
||||||
"{message}: [{exc.errno:d}] {exc.strerror}".format(
|
|
||||||
message=error_message, exc=exc
|
|
||||||
)
|
|
||||||
)
|
|
||||||
raise error
|
|
||||||
|
|
||||||
fork_then_exit_parent(error_message="Failed first fork")
|
|
||||||
os.setsid()
|
|
||||||
fork_then_exit_parent(error_message="Failed second fork")
|
|
||||||
|
|
||||||
|
|
||||||
def is_process_started_by_init():
|
|
||||||
""" Determine whether the current process is started by `init`.
|
|
||||||
|
|
||||||
:return: ``True`` iff the parent process is `init`; otherwise
|
|
||||||
``False``.
|
|
||||||
|
|
||||||
The `init` process is the one with process ID of 1.
|
|
||||||
|
|
||||||
"""
|
|
||||||
result = False
|
|
||||||
|
|
||||||
init_pid = 1
|
|
||||||
if os.getppid() == init_pid:
|
|
||||||
result = True
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def is_socket(fd):
|
|
||||||
""" Determine whether the file descriptor is a socket.
|
|
||||||
|
|
||||||
:param fd: The file descriptor to interrogate.
|
|
||||||
:return: ``True`` iff the file descriptor is a socket; otherwise
|
|
||||||
``False``.
|
|
||||||
|
|
||||||
Query the socket type of `fd`. If there is no error, the file is a
|
|
||||||
socket.
|
|
||||||
|
|
||||||
"""
|
|
||||||
result = False
|
|
||||||
|
|
||||||
file_socket = socket.fromfd(fd, socket.AF_INET, socket.SOCK_RAW)
|
|
||||||
|
|
||||||
try:
|
|
||||||
socket_type = file_socket.getsockopt(socket.SOL_SOCKET, socket.SO_TYPE)
|
|
||||||
except socket.error as exc:
|
|
||||||
exc_errno = exc.args[0]
|
|
||||||
if exc_errno == errno.ENOTSOCK:
|
|
||||||
# Socket operation on non-socket.
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
# Some other socket error.
|
|
||||||
result = True
|
|
||||||
else:
|
|
||||||
# No error getting socket type.
|
|
||||||
result = True
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def is_process_started_by_superserver():
|
|
||||||
""" Determine whether the current process is started by the superserver.
|
|
||||||
|
|
||||||
:return: ``True`` if this process was started by the internet
|
|
||||||
superserver; otherwise ``False``.
|
|
||||||
|
|
||||||
The internet superserver creates a network socket, and
|
|
||||||
attaches it to the standard streams of the child process. If
|
|
||||||
that is the case for this process, return ``True``, otherwise
|
|
||||||
``False``.
|
|
||||||
|
|
||||||
"""
|
|
||||||
result = False
|
|
||||||
|
|
||||||
stdin_fd = sys.__stdin__.fileno()
|
|
||||||
if is_socket(stdin_fd):
|
|
||||||
result = True
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def is_detach_process_context_required():
|
|
||||||
""" Determine whether detaching the process context is required.
|
|
||||||
|
|
||||||
:return: ``True`` iff the process is already detached; otherwise
|
|
||||||
``False``.
|
|
||||||
|
|
||||||
The process environment is interrogated for the following:
|
|
||||||
|
|
||||||
* Process was started by `init`; or
|
|
||||||
|
|
||||||
* Process was started by `inetd`.
|
|
||||||
|
|
||||||
If any of the above are true, the process is deemed to be already
|
|
||||||
detached.
|
|
||||||
|
|
||||||
"""
|
|
||||||
result = True
|
|
||||||
if is_process_started_by_init() or is_process_started_by_superserver():
|
|
||||||
result = False
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def close_file_descriptor_if_open(fd):
|
|
||||||
""" Close a file descriptor if already open.
|
|
||||||
|
|
||||||
:param fd: The file descriptor to close.
|
|
||||||
:return: ``None``.
|
|
||||||
|
|
||||||
Close the file descriptor `fd`, suppressing an error in the
|
|
||||||
case the file was not open.
|
|
||||||
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
os.close(fd)
|
|
||||||
except EnvironmentError as exc:
|
|
||||||
if exc.errno == errno.EBADF:
|
|
||||||
# File descriptor was not open.
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
error = DaemonOSEnvironmentError(
|
|
||||||
"Failed to close file descriptor {fd:d} ({exc})".format(fd=fd, exc=exc)
|
|
||||||
)
|
|
||||||
raise error
|
|
||||||
|
|
||||||
|
|
||||||
MAXFD = 2048
|
|
||||||
|
|
||||||
|
|
||||||
def get_maximum_file_descriptors():
|
|
||||||
""" Get the maximum number of open file descriptors for this process.
|
|
||||||
|
|
||||||
:return: The number (integer) to use as the maximum number of open
|
|
||||||
files for this process.
|
|
||||||
|
|
||||||
The maximum is the process hard resource limit of maximum number of
|
|
||||||
open file descriptors. If the limit is “infinity”, a default value
|
|
||||||
of ``MAXFD`` is returned.
|
|
||||||
|
|
||||||
"""
|
|
||||||
limits = resource.getrlimit(resource.RLIMIT_NOFILE)
|
|
||||||
result = limits[1]
|
|
||||||
if result == resource.RLIM_INFINITY:
|
|
||||||
result = MAXFD
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def close_all_open_files(exclude=set()):
|
|
||||||
""" Close all open file descriptors.
|
|
||||||
|
|
||||||
:param exclude: Collection of file descriptors to skip when closing
|
|
||||||
files.
|
|
||||||
:return: ``None``.
|
|
||||||
|
|
||||||
Closes every file descriptor (if open) of this process. If
|
|
||||||
specified, `exclude` is a set of file descriptors to *not*
|
|
||||||
close.
|
|
||||||
|
|
||||||
"""
|
|
||||||
maxfd = get_maximum_file_descriptors()
|
|
||||||
for fd in reversed(range(maxfd)):
|
|
||||||
if fd not in exclude:
|
|
||||||
close_file_descriptor_if_open(fd)
|
|
||||||
|
|
||||||
|
|
||||||
def redirect_stream(system_stream, target_stream):
|
|
||||||
""" Redirect a system stream to a specified file.
|
|
||||||
|
|
||||||
:param standard_stream: A file object representing a standard I/O
|
|
||||||
stream.
|
|
||||||
:param target_stream: The target file object for the redirected
|
|
||||||
stream, or ``None`` to specify the null device.
|
|
||||||
:return: ``None``.
|
|
||||||
|
|
||||||
`system_stream` is a standard system stream such as
|
|
||||||
``sys.stdout``. `target_stream` is an open file object that
|
|
||||||
should replace the corresponding system stream object.
|
|
||||||
|
|
||||||
If `target_stream` is ``None``, defaults to opening the
|
|
||||||
operating system's null device and using its file descriptor.
|
|
||||||
|
|
||||||
"""
|
|
||||||
if target_stream is None:
|
|
||||||
target_fd = os.open(os.devnull, os.O_RDWR)
|
|
||||||
else:
|
|
||||||
target_fd = target_stream.fileno()
|
|
||||||
os.dup2(target_fd, system_stream.fileno())
|
|
||||||
|
|
||||||
|
|
||||||
def make_default_signal_map():
|
|
||||||
""" Make the default signal map for this system.
|
|
||||||
|
|
||||||
:return: A mapping from signal number to handler object.
|
|
||||||
|
|
||||||
The signals available differ by system. The map will not contain
|
|
||||||
any signals not defined on the running system.
|
|
||||||
|
|
||||||
"""
|
|
||||||
name_map = {
|
|
||||||
"SIGTSTP": None,
|
|
||||||
"SIGTTIN": None,
|
|
||||||
"SIGTTOU": None,
|
|
||||||
"SIGTERM": "terminate",
|
|
||||||
}
|
|
||||||
signal_map = dict(
|
|
||||||
(getattr(signal, name), target)
|
|
||||||
for (name, target) in name_map.items()
|
|
||||||
if hasattr(signal, name)
|
|
||||||
)
|
|
||||||
|
|
||||||
return signal_map
|
|
||||||
|
|
||||||
|
|
||||||
def set_signal_handlers(signal_handler_map):
|
|
||||||
""" Set the signal handlers as specified.
|
|
||||||
|
|
||||||
:param signal_handler_map: A map from signal number to handler
|
|
||||||
object.
|
|
||||||
:return: ``None``.
|
|
||||||
|
|
||||||
See the `signal` module for details on signal numbers and signal
|
|
||||||
handlers.
|
|
||||||
|
|
||||||
"""
|
|
||||||
for (signal_number, handler) in signal_handler_map.items():
|
|
||||||
signal.signal(signal_number, handler)
|
|
||||||
|
|
||||||
|
|
||||||
def register_atexit_function(func):
|
|
||||||
""" Register a function for processing at program exit.
|
|
||||||
|
|
||||||
:param func: A callable function expecting no arguments.
|
|
||||||
:return: ``None``.
|
|
||||||
|
|
||||||
The function `func` is registered for a call with no arguments
|
|
||||||
at program exit.
|
|
||||||
|
|
||||||
"""
|
|
||||||
atexit.register(func)
|
|
||||||
|
|
||||||
|
|
||||||
def _chain_exception_from_existing_exception_context(exc, as_cause=False):
|
|
||||||
""" Decorate the specified exception with the existing exception context.
|
|
||||||
|
|
||||||
:param exc: The exception instance to decorate.
|
|
||||||
:param as_cause: If true, the existing context is declared to be
|
|
||||||
the cause of the exception.
|
|
||||||
:return: ``None``.
|
|
||||||
|
|
||||||
:PEP:`344` describes syntax and attributes (`__traceback__`,
|
|
||||||
`__context__`, `__cause__`) for use in exception chaining.
|
|
||||||
|
|
||||||
Python 2 does not have that syntax, so this function decorates
|
|
||||||
the exception with values from the current exception context.
|
|
||||||
|
|
||||||
"""
|
|
||||||
(existing_exc_type, existing_exc, existing_traceback) = sys.exc_info()
|
|
||||||
if as_cause:
|
|
||||||
exc.__cause__ = existing_exc
|
|
||||||
else:
|
|
||||||
exc.__context__ = existing_exc
|
|
||||||
exc.__traceback__ = existing_traceback
|
|
||||||
|
|
||||||
|
|
||||||
# Local variables:
|
|
||||||
# coding: utf-8
|
|
||||||
# mode: python
|
|
||||||
# End:
|
|
||||||
# vim: fileencoding=utf-8 filetype=python :
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# daemon/pidfile.py
|
|
||||||
# Part of ‘python-daemon’, an implementation of PEP 3143.
|
|
||||||
#
|
|
||||||
# Copyright © 2008–2015 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 Apache License, version 2.0 as published by the
|
|
||||||
# Apache Software Foundation.
|
|
||||||
# No warranty expressed or implied. See the file ‘LICENSE.ASF-2’ for details.
|
|
||||||
|
|
||||||
""" Lockfile behaviour implemented via Unix PID files.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import absolute_import, unicode_literals
|
|
||||||
|
|
||||||
from lockfile.pidlockfile import PIDLockFile
|
|
||||||
|
|
||||||
|
|
||||||
class TimeoutPIDLockFile(PIDLockFile, object):
|
|
||||||
""" Lockfile with default timeout, implemented as a Unix PID file.
|
|
||||||
|
|
||||||
This uses the ``PIDLockFile`` implementation, with the
|
|
||||||
following changes:
|
|
||||||
|
|
||||||
* The `acquire_timeout` parameter to the initialiser will be
|
|
||||||
used as the default `timeout` parameter for the `acquire`
|
|
||||||
method.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, path, acquire_timeout=None, *args, **kwargs):
|
|
||||||
""" Set up the parameters of a TimeoutPIDLockFile.
|
|
||||||
|
|
||||||
:param path: Filesystem path to the PID file.
|
|
||||||
:param acquire_timeout: Value to use by default for the
|
|
||||||
`acquire` call.
|
|
||||||
:return: ``None``.
|
|
||||||
|
|
||||||
"""
|
|
||||||
self.acquire_timeout = acquire_timeout
|
|
||||||
super(TimeoutPIDLockFile, self).__init__(path, *args, **kwargs)
|
|
||||||
|
|
||||||
def acquire(self, timeout=None, *args, **kwargs):
|
|
||||||
""" Acquire the lock.
|
|
||||||
|
|
||||||
:param timeout: Specifies the timeout; see below for valid
|
|
||||||
values.
|
|
||||||
:return: ``None``.
|
|
||||||
|
|
||||||
The `timeout` defaults to the value set during
|
|
||||||
initialisation with the `acquire_timeout` parameter. It is
|
|
||||||
passed to `PIDLockFile.acquire`; see that method for
|
|
||||||
details.
|
|
||||||
|
|
||||||
"""
|
|
||||||
if timeout is None:
|
|
||||||
timeout = self.acquire_timeout
|
|
||||||
super(TimeoutPIDLockFile, self).acquire(timeout, *args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
# Local variables:
|
|
||||||
# coding: utf-8
|
|
||||||
# mode: python
|
|
||||||
# End:
|
|
||||||
# vim: fileencoding=utf-8 filetype=python :
|
|
||||||
@@ -1,322 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# daemon/runner.py
|
|
||||||
# Part of ‘python-daemon’, an implementation of PEP 3143.
|
|
||||||
#
|
|
||||||
# Copyright © 2009–2015 Ben Finney <ben+python@benfinney.id.au>
|
|
||||||
# Copyright © 2007–2008 Robert Niederreiter, Jens Klein
|
|
||||||
# Copyright © 2003 Clark Evans
|
|
||||||
# Copyright © 2002 Noah Spurrier
|
|
||||||
# Copyright © 2001 Jürgen Hermann
|
|
||||||
#
|
|
||||||
# This is free software: you may copy, modify, and/or distribute this work
|
|
||||||
# under the terms of the Apache License, version 2.0 as published by the
|
|
||||||
# Apache Software Foundation.
|
|
||||||
# No warranty expressed or implied. See the file ‘LICENSE.ASF-2’ for details.
|
|
||||||
|
|
||||||
""" Daemon runner library.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import absolute_import, unicode_literals
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
import signal
|
|
||||||
import errno
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Python 3 standard library.
|
|
||||||
ProcessLookupError
|
|
||||||
except NameError:
|
|
||||||
# No such class in Python 2.
|
|
||||||
ProcessLookupError = NotImplemented
|
|
||||||
|
|
||||||
import lockfile
|
|
||||||
|
|
||||||
from . import pidfile
|
|
||||||
from .daemon import basestring, unicode
|
|
||||||
from .daemon import DaemonContext
|
|
||||||
from .daemon import _chain_exception_from_existing_exception_context
|
|
||||||
|
|
||||||
|
|
||||||
class DaemonRunnerError(Exception):
|
|
||||||
""" Abstract base class for errors from DaemonRunner. """
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
self._chain_from_context()
|
|
||||||
|
|
||||||
super(DaemonRunnerError, self).__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
def _chain_from_context(self):
|
|
||||||
_chain_exception_from_existing_exception_context(self, as_cause=True)
|
|
||||||
|
|
||||||
|
|
||||||
class DaemonRunnerInvalidActionError(DaemonRunnerError, ValueError):
|
|
||||||
""" Raised when specified action for DaemonRunner is invalid. """
|
|
||||||
|
|
||||||
def _chain_from_context(self):
|
|
||||||
# This exception is normally not caused by another.
|
|
||||||
_chain_exception_from_existing_exception_context(self, as_cause=False)
|
|
||||||
|
|
||||||
|
|
||||||
class DaemonRunnerStartFailureError(DaemonRunnerError, RuntimeError):
|
|
||||||
""" Raised when failure starting DaemonRunner. """
|
|
||||||
|
|
||||||
|
|
||||||
class DaemonRunnerStopFailureError(DaemonRunnerError, RuntimeError):
|
|
||||||
""" Raised when failure stopping DaemonRunner. """
|
|
||||||
|
|
||||||
|
|
||||||
class DaemonRunner:
|
|
||||||
""" Controller for a callable running in a separate background process.
|
|
||||||
|
|
||||||
The first command-line argument is the action to take:
|
|
||||||
|
|
||||||
* 'start': Become a daemon and call `app.run()`.
|
|
||||||
* 'stop': Exit the daemon process specified in the PID file.
|
|
||||||
* 'restart': Stop, then start.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
__metaclass__ = type
|
|
||||||
|
|
||||||
start_message = "started with pid {pid:d}"
|
|
||||||
|
|
||||||
def __init__(self, app):
|
|
||||||
""" Set up the parameters of a new runner.
|
|
||||||
|
|
||||||
:param app: The application instance; see below.
|
|
||||||
:return: ``None``.
|
|
||||||
|
|
||||||
The `app` argument must have the following attributes:
|
|
||||||
|
|
||||||
* `stdin_path`, `stdout_path`, `stderr_path`: Filesystem paths
|
|
||||||
to open and replace the existing `sys.stdin`, `sys.stdout`,
|
|
||||||
`sys.stderr`.
|
|
||||||
|
|
||||||
* `pidfile_path`: Absolute filesystem path to a file that will
|
|
||||||
be used as the PID file for the daemon. If ``None``, no PID
|
|
||||||
file will be used.
|
|
||||||
|
|
||||||
* `pidfile_timeout`: Used as the default acquisition timeout
|
|
||||||
value supplied to the runner's PID lock file.
|
|
||||||
|
|
||||||
* `run`: Callable that will be invoked when the daemon is
|
|
||||||
started.
|
|
||||||
|
|
||||||
"""
|
|
||||||
self.parse_args()
|
|
||||||
self.app = app
|
|
||||||
self.daemon_context = DaemonContext()
|
|
||||||
self.daemon_context.stdin = open(app.stdin_path, "rt")
|
|
||||||
self.daemon_context.stdout = open(app.stdout_path, "w+t")
|
|
||||||
self.daemon_context.stderr = open(app.stderr_path, "w+t", buffering=0)
|
|
||||||
|
|
||||||
self.pidfile = None
|
|
||||||
if app.pidfile_path is not None:
|
|
||||||
self.pidfile = make_pidlockfile(app.pidfile_path, app.pidfile_timeout)
|
|
||||||
self.daemon_context.pidfile = self.pidfile
|
|
||||||
|
|
||||||
def _usage_exit(self, argv):
|
|
||||||
""" Emit a usage message, then exit.
|
|
||||||
|
|
||||||
:param argv: The command-line arguments used to invoke the
|
|
||||||
program, as a sequence of strings.
|
|
||||||
:return: ``None``.
|
|
||||||
|
|
||||||
"""
|
|
||||||
progname = os.path.basename(argv[0])
|
|
||||||
usage_exit_code = 2
|
|
||||||
action_usage = "|".join(self.action_funcs.keys())
|
|
||||||
message = "usage: {progname} {usage}".format(
|
|
||||||
progname=progname, usage=action_usage
|
|
||||||
)
|
|
||||||
emit_message(message)
|
|
||||||
sys.exit(usage_exit_code)
|
|
||||||
|
|
||||||
def parse_args(self, argv=None):
|
|
||||||
""" Parse command-line arguments.
|
|
||||||
|
|
||||||
:param argv: The command-line arguments used to invoke the
|
|
||||||
program, as a sequence of strings.
|
|
||||||
|
|
||||||
:return: ``None``.
|
|
||||||
|
|
||||||
The parser expects the first argument as the program name, the
|
|
||||||
second argument as the action to perform.
|
|
||||||
|
|
||||||
If the parser fails to parse the arguments, emit a usage
|
|
||||||
message and exit the program.
|
|
||||||
|
|
||||||
"""
|
|
||||||
if argv is None:
|
|
||||||
argv = sys.argv
|
|
||||||
|
|
||||||
min_args = 2
|
|
||||||
if len(argv) < min_args:
|
|
||||||
self._usage_exit(argv)
|
|
||||||
|
|
||||||
self.action = unicode(argv[1])
|
|
||||||
if self.action not in self.action_funcs:
|
|
||||||
self._usage_exit(argv)
|
|
||||||
|
|
||||||
def _start(self):
|
|
||||||
""" Open the daemon context and run the application.
|
|
||||||
|
|
||||||
:return: ``None``.
|
|
||||||
:raises DaemonRunnerStartFailureError: If the PID file cannot
|
|
||||||
be locked by this process.
|
|
||||||
|
|
||||||
"""
|
|
||||||
if is_pidfile_stale(self.pidfile):
|
|
||||||
self.pidfile.break_lock()
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.daemon_context.open()
|
|
||||||
except lockfile.AlreadyLocked:
|
|
||||||
error = DaemonRunnerStartFailureError(
|
|
||||||
"PID file {pidfile.path!r} already locked".format(pidfile=self.pidfile)
|
|
||||||
)
|
|
||||||
raise error
|
|
||||||
|
|
||||||
pid = os.getpid()
|
|
||||||
message = self.start_message.format(pid=pid)
|
|
||||||
emit_message(message)
|
|
||||||
|
|
||||||
self.app.run()
|
|
||||||
|
|
||||||
def _terminate_daemon_process(self):
|
|
||||||
""" Terminate the daemon process specified in the current PID file.
|
|
||||||
|
|
||||||
:return: ``None``.
|
|
||||||
:raises DaemonRunnerStopFailureError: If terminating the daemon
|
|
||||||
fails with an OS error.
|
|
||||||
|
|
||||||
"""
|
|
||||||
pid = self.pidfile.read_pid()
|
|
||||||
try:
|
|
||||||
os.kill(pid, signal.SIGTERM)
|
|
||||||
except OSError as exc:
|
|
||||||
error = DaemonRunnerStopFailureError(
|
|
||||||
"Failed to terminate {pid:d}: {exc}".format(pid=pid, exc=exc)
|
|
||||||
)
|
|
||||||
raise error
|
|
||||||
|
|
||||||
def _stop(self):
|
|
||||||
""" Exit the daemon process specified in the current PID file.
|
|
||||||
|
|
||||||
:return: ``None``.
|
|
||||||
:raises DaemonRunnerStopFailureError: If the PID file is not
|
|
||||||
already locked.
|
|
||||||
|
|
||||||
"""
|
|
||||||
if not self.pidfile.is_locked():
|
|
||||||
error = DaemonRunnerStopFailureError(
|
|
||||||
"PID file {pidfile.path!r} not locked".format(pidfile=self.pidfile)
|
|
||||||
)
|
|
||||||
raise error
|
|
||||||
|
|
||||||
if is_pidfile_stale(self.pidfile):
|
|
||||||
self.pidfile.break_lock()
|
|
||||||
else:
|
|
||||||
self._terminate_daemon_process()
|
|
||||||
|
|
||||||
def _restart(self):
|
|
||||||
""" Stop, then start.
|
|
||||||
"""
|
|
||||||
self._stop()
|
|
||||||
self._start()
|
|
||||||
|
|
||||||
action_funcs = {
|
|
||||||
"start": _start,
|
|
||||||
"stop": _stop,
|
|
||||||
"restart": _restart,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _get_action_func(self):
|
|
||||||
""" Get the function for the specified action.
|
|
||||||
|
|
||||||
:return: The function object corresponding to the specified
|
|
||||||
action.
|
|
||||||
:raises DaemonRunnerInvalidActionError: if the action is
|
|
||||||
unknown.
|
|
||||||
|
|
||||||
The action is specified by the `action` attribute, which is set
|
|
||||||
during `parse_args`.
|
|
||||||
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
func = self.action_funcs[self.action]
|
|
||||||
except KeyError:
|
|
||||||
error = DaemonRunnerInvalidActionError(
|
|
||||||
"Unknown action: {action!r}".format(action=self.action)
|
|
||||||
)
|
|
||||||
raise error
|
|
||||||
return func
|
|
||||||
|
|
||||||
def do_action(self):
|
|
||||||
""" Perform the requested action.
|
|
||||||
|
|
||||||
:return: ``None``.
|
|
||||||
|
|
||||||
The action is specified by the `action` attribute, which is set
|
|
||||||
during `parse_args`.
|
|
||||||
|
|
||||||
"""
|
|
||||||
func = self._get_action_func()
|
|
||||||
func(self)
|
|
||||||
|
|
||||||
|
|
||||||
def emit_message(message, stream=None):
|
|
||||||
""" Emit a message to the specified stream (default `sys.stderr`). """
|
|
||||||
if stream is None:
|
|
||||||
stream = sys.stderr
|
|
||||||
stream.write("{message}\n".format(message=message))
|
|
||||||
stream.flush()
|
|
||||||
|
|
||||||
|
|
||||||
def make_pidlockfile(path, acquire_timeout):
|
|
||||||
""" Make a PIDLockFile instance with the given filesystem path. """
|
|
||||||
if not isinstance(path, basestring):
|
|
||||||
error = ValueError("Not a filesystem path: {path!r}".format(path=path))
|
|
||||||
raise error
|
|
||||||
if not os.path.isabs(path):
|
|
||||||
error = ValueError("Not an absolute path: {path!r}".format(path=path))
|
|
||||||
raise error
|
|
||||||
lockfile = pidfile.TimeoutPIDLockFile(path, acquire_timeout)
|
|
||||||
|
|
||||||
return lockfile
|
|
||||||
|
|
||||||
|
|
||||||
def is_pidfile_stale(pidfile):
|
|
||||||
""" Determine whether a PID file is stale.
|
|
||||||
|
|
||||||
:return: ``True`` iff the PID file is stale; otherwise ``False``.
|
|
||||||
|
|
||||||
The PID file is “stale” if its contents are valid but do not
|
|
||||||
match the PID of a currently-running process.
|
|
||||||
|
|
||||||
"""
|
|
||||||
result = False
|
|
||||||
|
|
||||||
pidfile_pid = pidfile.read_pid()
|
|
||||||
if pidfile_pid is not None:
|
|
||||||
try:
|
|
||||||
os.kill(pidfile_pid, signal.SIG_DFL)
|
|
||||||
except ProcessLookupError:
|
|
||||||
# The specified PID does not exist.
|
|
||||||
result = True
|
|
||||||
except OSError as exc:
|
|
||||||
if exc.errno == errno.ESRCH:
|
|
||||||
# Under Python 2, process lookup error is an OSError.
|
|
||||||
# The specified PID does not exist.
|
|
||||||
result = True
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
# Local variables:
|
|
||||||
# coding: utf-8
|
|
||||||
# mode: python
|
|
||||||
# End:
|
|
||||||
# vim: fileencoding=utf-8 filetype=python :
|
|
||||||
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,11 @@
|
|||||||
|
"""hbd package - scaffolding for heartbeat daemon
|
||||||
|
|
||||||
|
This package contains the refactored modules for the original monolithic
|
||||||
|
`hbd` script. The initial implementation contains small scaffolds so you can
|
||||||
|
start moving functionality into the package.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__all__ = ["main", "__version__"]
|
||||||
|
__version__ = "0.1"
|
||||||
|
|
||||||
|
from .cli import main
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
"""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()
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Configuration loader and defaults for hbd."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except Exception:
|
||||||
|
yaml = None
|
||||||
|
|
||||||
|
DEFAULTS = {
|
||||||
|
"hb_port": 50003,
|
||||||
|
"hbd_port": 50004,
|
||||||
|
"hbd_host": "",
|
||||||
|
"pickfile": "/tmp/hb.pick",
|
||||||
|
"logfile": "/var/log/heartbeat.log",
|
||||||
|
"logfmt": "text",
|
||||||
|
"pushsrv": "pushover",
|
||||||
|
"interval": 20,
|
||||||
|
"grace": 2,
|
||||||
|
"dyndomains": ["wrede.org"],
|
||||||
|
"watchhosts": [],
|
||||||
|
"dyndnshosts": [],
|
||||||
|
"drophosts": [],
|
||||||
|
"nsupdate_bin": "/usr/bin/nsupdate",
|
||||||
|
"foreground": False,
|
||||||
|
"verbose": False,
|
||||||
|
"debug": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path=None):
|
||||||
|
"""Load configuration from a YAML file and merge with defaults.
|
||||||
|
|
||||||
|
If YAML is not available or the file does not exist, defaults are returned.
|
||||||
|
"""
|
||||||
|
cfg = DEFAULTS.copy()
|
||||||
|
if not path:
|
||||||
|
# default path (~/.hb.yaml)
|
||||||
|
path = os.path.join(os.path.expanduser("~"), ".hb.yaml")
|
||||||
|
|
||||||
|
if os.path.exists(path):
|
||||||
|
if yaml:
|
||||||
|
with open(path) as fh:
|
||||||
|
data = yaml.safe_load(fh) or {}
|
||||||
|
# only keep known keys
|
||||||
|
for k, v in data.items():
|
||||||
|
if k in cfg:
|
||||||
|
cfg[k] = v
|
||||||
|
else:
|
||||||
|
# ignore unknown keys for now
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# yaml not installed: do not attempt to parse; user must ensure defaults
|
||||||
|
pass
|
||||||
|
return cfg
|
||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
"""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
|
||||||
+236
@@ -0,0 +1,236 @@
|
|||||||
|
"""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 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
|
||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
"""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)
|
||||||
|
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""Message encoding/decoding utilities for hbd protocol."""
|
||||||
|
from typing import Dict, Any
|
||||||
|
import zlib
|
||||||
|
|
||||||
|
|
||||||
|
def dicttos(ID: str, d: Dict[str, Any], compress: bool = False):
|
||||||
|
"""Serialize a dict to protocol message bytes.
|
||||||
|
|
||||||
|
If compress is True, the payload is zlib-compressed and the message is
|
||||||
|
prefixed with `!ID:` as the original script did. Otherwise the format is
|
||||||
|
`ID:key=value;...` (bytes).
|
||||||
|
"""
|
||||||
|
s = []
|
||||||
|
for k in d:
|
||||||
|
v = d[k]
|
||||||
|
if isinstance(v, float):
|
||||||
|
s.append(f"{k}={v:0.5f}")
|
||||||
|
else:
|
||||||
|
s.append(f"{k}={v}")
|
||||||
|
pk = ";".join(s)
|
||||||
|
if compress:
|
||||||
|
zpk = zlib.compress(pk.encode(), 6)
|
||||||
|
hdr = ("!" + ID + ":").encode()
|
||||||
|
return hdr + zpk
|
||||||
|
else:
|
||||||
|
return (ID + ":" + pk).encode()
|
||||||
|
|
||||||
|
|
||||||
|
def stodict(msg: bytes):
|
||||||
|
"""Deserialize a protocol message into a dict.
|
||||||
|
|
||||||
|
Mirrors original behaviour: detects compressed messages starting with
|
||||||
|
'!' and decodes accordingly. Returns a dict with key 'ID' set to the
|
||||||
|
message ID and the parsed key/value pairs.
|
||||||
|
"""
|
||||||
|
d = {}
|
||||||
|
if len(msg) > 0 and chr(msg[0]) == "!":
|
||||||
|
# message is: b'!ID:' + compressed_payload
|
||||||
|
# original code used msg[1:4].decode() for ID (3 bytes including colon)
|
||||||
|
try:
|
||||||
|
pk = zlib.decompress(msg[5:]).decode()
|
||||||
|
except Exception:
|
||||||
|
# malformed compressed payload
|
||||||
|
return {}
|
||||||
|
d["ID"] = msg[1:4].decode()
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
r0 = msg.split(b":", 1)
|
||||||
|
pk = r0[1].decode()
|
||||||
|
d["ID"] = r0[0].decode()
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
if not pk:
|
||||||
|
return d
|
||||||
|
parts = pk.split(";")
|
||||||
|
for v in parts:
|
||||||
|
if not v:
|
||||||
|
continue
|
||||||
|
vr = v.split("=", 1)
|
||||||
|
k = vr[0].strip()
|
||||||
|
if len(vr) == 1:
|
||||||
|
d[k] = None
|
||||||
|
else:
|
||||||
|
val = vr[1].strip()
|
||||||
|
if val and val[0].isdigit():
|
||||||
|
try:
|
||||||
|
val_e = eval(val)
|
||||||
|
except Exception:
|
||||||
|
val_e = val
|
||||||
|
d[k] = val_e
|
||||||
|
else:
|
||||||
|
d[k] = val
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def oldmtodict(msg: bytes):
|
||||||
|
"""Compatibility wrapper for old-style messages (no ID prefix).
|
||||||
|
|
||||||
|
The original implementation prefixed with 'HTB:' and called stodict.
|
||||||
|
"""
|
||||||
|
return stodict(b"HTB:" + msg)
|
||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
"""Server runtime: starts UDP listener, HTTP server and websocket stubs."""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from . import udp
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_async(config):
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
# shared runtime collections and helpers
|
||||||
|
msgs = []
|
||||||
|
|
||||||
|
# prepare runtime dependencies
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import hbdclass
|
||||||
|
from . import http as http_mod
|
||||||
|
from . import ws as ws_mod
|
||||||
|
from . import dns as dns_mod
|
||||||
|
from . import notify as notify_mod
|
||||||
|
|
||||||
|
notify_mod.setup(config)
|
||||||
|
|
||||||
|
def log(host, m, service=None):
|
||||||
|
ts = time.time()
|
||||||
|
s = f"{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(ts))} {host or ''} {m}"
|
||||||
|
msgs.append(s)
|
||||||
|
logger.info(s)
|
||||||
|
|
||||||
|
email = notify_mod.email
|
||||||
|
pushmsg = notify_mod.pushmsg_from_config
|
||||||
|
msg_to_websockets = ws_mod.broadcast
|
||||||
|
|
||||||
|
# UDP server endpoint (handler wired to handle_datagram with context)
|
||||||
|
bind_addr = ("0.0.0.0", config.get("hb_port", 50003))
|
||||||
|
logger.info("Starting UDP server on %s:%s", *bind_addr)
|
||||||
|
|
||||||
|
def udp_handler(msg, addr, transport):
|
||||||
|
ctx = dict(
|
||||||
|
config=config,
|
||||||
|
hbdclass=hbdclass,
|
||||||
|
log=log,
|
||||||
|
email=email,
|
||||||
|
pushmsg=pushmsg,
|
||||||
|
msg_to_websockets=msg_to_websockets,
|
||||||
|
msgs=msgs,
|
||||||
|
DEBUG=config.get("debug", 0),
|
||||||
|
verbose=config.get("verbose", False),
|
||||||
|
)
|
||||||
|
udp.handle_datagram(msg, addr, transport, ctx)
|
||||||
|
|
||||||
|
transport, protocol = await loop.create_datagram_endpoint(
|
||||||
|
lambda: udp.EchoServerProtocol(config=config, handler=udp_handler),
|
||||||
|
local_addr=bind_addr,
|
||||||
|
)
|
||||||
|
|
||||||
|
# HTTP server (runs in its own thread)
|
||||||
|
try:
|
||||||
|
handler_cls = http_mod.make_handler_class(
|
||||||
|
config=config,
|
||||||
|
hbdclass=hbdclass,
|
||||||
|
msgs_getter=lambda: msgs,
|
||||||
|
log=log,
|
||||||
|
email=email,
|
||||||
|
pushmsg=pushmsg,
|
||||||
|
msg_to_websockets=msg_to_websockets,
|
||||||
|
tcss=None,
|
||||||
|
DEBUG=config.get("debug", 0),
|
||||||
|
verbose=config.get("verbose", False),
|
||||||
|
get_now=lambda: time.time(),
|
||||||
|
VER="",
|
||||||
|
)
|
||||||
|
serv = http_mod.HttpServer((config.get("hbd_host", ""), config.get("hbd_port", 50004)), handler_cls)
|
||||||
|
http_thread = threading.Thread(target=serv.serve_forever, daemon=True)
|
||||||
|
http_thread.start()
|
||||||
|
logger.info("HTTP server started on %s:%s", config.get("hbd_host", ""), config.get("hbd_port", 50004))
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("failed to start HTTP server: %s", e)
|
||||||
|
|
||||||
|
# start dns update thread
|
||||||
|
dns_mod.start_dns_thread(hbdclass, config, log=log, email=email)
|
||||||
|
logger.info("dns update thread started")
|
||||||
|
|
||||||
|
# Start the websocket servers as a background task
|
||||||
|
try:
|
||||||
|
ws_task = asyncio.create_task(
|
||||||
|
ws_mod.start(
|
||||||
|
host=config.get("hbd_host", ""),
|
||||||
|
ws_port=config.get("ws_port", 50005),
|
||||||
|
wss_port=config.get("wss_port", None),
|
||||||
|
ssl_context=None,
|
||||||
|
get_hosts=lambda: [hbdclass.Host.hosts[h].stateinfo() for h in sorted(hbdclass.Host.hosts)],
|
||||||
|
get_msgs=lambda: msgs,
|
||||||
|
verbose=config.get("verbose", False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info("WebSocket task started")
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("websocket server failed to start: %s", e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# run forever
|
||||||
|
await asyncio.Future()
|
||||||
|
finally:
|
||||||
|
transport.close()
|
||||||
|
try:
|
||||||
|
serv.shutdown()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
ws_task.cancel()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def run(config):
|
||||||
|
"""Start the hbd service (blocking).
|
||||||
|
|
||||||
|
This is a thin wrapper around asyncio.run to host the async services.
|
||||||
|
"""
|
||||||
|
logging.basicConfig(level=logging.DEBUG if config.get("debug", 0) > 0 else logging.INFO)
|
||||||
|
try:
|
||||||
|
asyncio.run(_run_async(config))
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Shutting down (KeyboardInterrupt)")
|
||||||
+235
@@ -0,0 +1,235 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""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)
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
Metadata-Version: 2.4
|
||||||
|
Name: heartbeat
|
||||||
|
Version: 0.1.0
|
||||||
|
Summary: Heartbeat daemon (hbd) — receive heartbeats and act on them
|
||||||
|
Author: heartbeat contributors
|
||||||
|
License: MIT
|
||||||
|
Keywords: heartbeat,monitoring,dns,websocket
|
||||||
|
Requires-Python: >=3.10
|
||||||
|
Description-Content-Type: text/markdown
|
||||||
|
Requires-Dist: websockets>=13.2
|
||||||
|
Requires-Dist: mattermostdriver>=7.3.0
|
||||||
|
Requires-Dist: PyYAML>=6.0
|
||||||
|
Requires-Dist: fastapi>=0.95.0
|
||||||
|
Provides-Extra: dev
|
||||||
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
||||||
|
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
||||||
|
Requires-Dist: flake8>=5.0; extra == "dev"
|
||||||
|
Requires-Dist: mypy>=1.10; extra == "dev"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Heartbeat Daemon (hbd) ✅
|
||||||
|
|
||||||
|
A lightweight daemon that listens for UDP heartbeat messages and acts on them: keeps host state, optionally updates DNS records via `nsupdate`, forwards messages to WebSocket clients, and sends notifications (email, Pushover, Mattermost, Signal). It is a refactor of a previously monolithic script into a modular Python package (`hbd`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📌 Features
|
||||||
|
|
||||||
|
- Receive and parse heartbeat datagrams (text or zlib-compressed) ✅
|
||||||
|
- Maintain host state and detect up/down transitions ✅
|
||||||
|
- Queue DNS updates via `nsupdate` and run them in a background thread ✅
|
||||||
|
- WebSocket API for live updates (hosts & messages) ✅
|
||||||
|
- Notification pipeline (email, Pushover, Mattermost, Signal) ✅
|
||||||
|
- Modular codebase suitable for unit testing and CI ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ Quickstart
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- Python 3.10+ (project uses language features from recent Python)
|
||||||
|
- `nsupdate` (for DNS updates) if using dynamic DNS
|
||||||
|
|
||||||
|
Install dependencies (recommended into a venv):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install -r requirements.txt
|
||||||
|
# for development/testing tools
|
||||||
|
python -m pip install -r requirements-dev.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the daemon (example):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# run with default config lookup (~/.hb.yaml)
|
||||||
|
PYTHONPATH=. hbd -c .hb.yaml -f -v
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also run it directly via the package entrypoint after installation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m hbd.cli -c /path/to/config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐞 Debugging in VS Code
|
||||||
|
|
||||||
|
This repository includes a ready-to-use `.vscode/launch.json` with configurations to run or attach the VS Code debugger to `hbd`.
|
||||||
|
|
||||||
|
- Ensure the **Python** extension is installed and select the project `.venv` as the interpreter (bottom-left of VS Code).
|
||||||
|
- Use **F5** and pick one of these configurations from the Run view:
|
||||||
|
- **Python: Run hbd (module)** — runs `hbd.cli` as a module and sets `PYTHONPATH` to the workspace root (recommended).
|
||||||
|
- **Python: Run hbd with debugpy (listen)** — launches `debugpy` and `hbd` together; useful when you want the process to listen for a debugger.
|
||||||
|
- **Python: Attach (localhost:5678)** — attach the debugger to a running process started with `debugpy`.
|
||||||
|
|
||||||
|
To start `hbd` manually and wait for the debugger to attach, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=. python -m debugpy --listen 5678 --wait-for-client -m hbd.cli -c .hb.yaml -f -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Set breakpoints in modules such as `hbd/udp.py`, `hbd/dns.py`, or `hbd/server.py`, and use the **Attach** configuration to connect. Use `justMyCode: false` if you need to step into third-party code.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠 Configuration
|
||||||
|
|
||||||
|
`hbd` reads YAML configuration (optional). If `PyYAML` is not installed, built-in defaults are used. Example configuration keys (see `hbd/config.py`):
|
||||||
|
|
||||||
|
- `hb_port`: UDP port to listen for heartbeats (default: 50003)
|
||||||
|
- `hbd_port`: internal control port (default: 50004)
|
||||||
|
- `hbd_host`: bind address for HTTP/WSS
|
||||||
|
- `pickfile`: path for persisted state
|
||||||
|
- `logfile`: path to log file
|
||||||
|
- `logfmt`: `text` or `msg`
|
||||||
|
- `pushsrv`: push service (`pushover`|`mattermost`|`all`)
|
||||||
|
- `interval` / `grace`: heartbeat timing configuration
|
||||||
|
- `dyndomains`: list of dyndomains to update via `nsupdate`
|
||||||
|
- `nsupdate_bin`: path to nsupdate binary
|
||||||
|
|
||||||
|
Example `.hb.yaml` (minimal):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
hbd_host: 0.0.0.0
|
||||||
|
hbd_port: 50004
|
||||||
|
dyndomains:
|
||||||
|
- example.com
|
||||||
|
nsupdate_bin: /usr/bin/nsupdate
|
||||||
|
pushsrv: pushover
|
||||||
|
```
|
||||||
|
|
||||||
|
> Tip: `config.DEFAULTS` in `hbd/config.py` contains the canonical defaults and accepted configuration keys.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Architecture & Modules
|
||||||
|
|
||||||
|
- `hbd.proto` — serialization/deserialization of heartbeat messages (supports compressed payloads)
|
||||||
|
- `hbd.udp` — UDP parsing and `handle_datagram` implementation (main state machine)
|
||||||
|
- `hbd.dns` — `create_nsupdate_payload`, `nsupdate`, and a background DNS thread (`start_dns_thread`)
|
||||||
|
- `hbd.notify` — email and push notification helpers
|
||||||
|
- `hbd.ws` — WebSocket server and thread-safe broadcast helpers
|
||||||
|
- `hbd.http` — HTTP handler factory for the status UI/API
|
||||||
|
- `hbd.utils` — small utility helpers (`shortname`, `dur`, `initlog`)
|
||||||
|
- `hbd.cli` — CLI entrypoint and argument parsing
|
||||||
|
- `hbd.server` — async orchestration to run UDP/HTTP/WSS components
|
||||||
|
|
||||||
|
This modular layout makes the code easier to test and maintain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Testing & Dev
|
||||||
|
|
||||||
|
Tests are implemented using `unittest` and additional tests rely on `pytest` if you prefer. To run tests locally without installing anything beyond the dev requirements:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# with project root on PYTHONPATH
|
||||||
|
PYTHONPATH=. python -m unittest discover -v
|
||||||
|
# or with pytest if installed
|
||||||
|
pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Developer tooling included:
|
||||||
|
- `pyproject.toml` — project metadata and dependencies
|
||||||
|
- `requirements-dev.txt` — dev/test dependencies
|
||||||
|
- `tox.ini` — convenience wrappers for running tests, lint, and mypy
|
||||||
|
|
||||||
|
To run linters and type checks locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# after installing dev deps
|
||||||
|
tox -e lint
|
||||||
|
tox -e mypy
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Running in production
|
||||||
|
|
||||||
|
- Use your system service manager (systemd, launchd, etc.) to run `hbd` in the background.
|
||||||
|
- Ensure `nsupdate` and necessary credentials are available for dynamic DNS updates.
|
||||||
|
- Configure TLS for WSS if you enable secure websockets.
|
||||||
|
|
||||||
|
> Note: The project contains a small example for obtaining DNS-verified certs (certbot with RFC2136) — see earlier commit history or ask me to re-add the example to this README if you want it documented here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
Contributions welcome! Please:
|
||||||
|
1. Open an issue to discuss larger changes.
|
||||||
|
2. Create a topic branch and a clear PR.
|
||||||
|
3. Add tests for new features and run linters.
|
||||||
|
4. Keep changes focused and documented.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📜 License
|
||||||
|
|
||||||
|
This repository is licensed under the MIT license. See `LICENSE` for details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
If you'd like, I can also:
|
||||||
|
- add a **GitHub Actions** workflow that runs tests and lint on push/PR 🔁
|
||||||
|
- add a `CONTRIBUTING.md` template for PRs and code style 💬
|
||||||
|
|
||||||
|
Which one should I do next? ✨
|
||||||
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
README.md
|
||||||
|
pyproject.toml
|
||||||
|
hbd/__init__.py
|
||||||
|
hbd/cli.py
|
||||||
|
hbd/config.py
|
||||||
|
hbd/dns.py
|
||||||
|
hbd/http.py
|
||||||
|
hbd/notify.py
|
||||||
|
hbd/proto.py
|
||||||
|
hbd/server.py
|
||||||
|
hbd/udp.py
|
||||||
|
hbd/utils.py
|
||||||
|
hbd/ws.py
|
||||||
|
heartbeat.egg-info/PKG-INFO
|
||||||
|
heartbeat.egg-info/SOURCES.txt
|
||||||
|
heartbeat.egg-info/dependency_links.txt
|
||||||
|
heartbeat.egg-info/entry_points.txt
|
||||||
|
heartbeat.egg-info/requires.txt
|
||||||
|
heartbeat.egg-info/top_level.txt
|
||||||
|
tests/test_dns.py
|
||||||
|
tests/test_handle_datagram.py
|
||||||
|
tests/test_proto.py
|
||||||
|
tests/test_udp.py
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[console_scripts]
|
||||||
|
hbd = hbd.cli:main
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
websockets>=13.2
|
||||||
|
mattermostdriver>=7.3.0
|
||||||
|
PyYAML>=6.0
|
||||||
|
fastapi>=0.95.0
|
||||||
|
|
||||||
|
[dev]
|
||||||
|
pytest>=7.0
|
||||||
|
pytest-cov>=4.0
|
||||||
|
flake8>=5.0
|
||||||
|
mypy>=1.10
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
hbd
|
||||||
Generated
+1298
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61.0", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "heartbeat"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Heartbeat daemon (hbd) — receive heartbeats and act on them"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
license = { text = "MIT" }
|
||||||
|
keywords = ["heartbeat", "monitoring", "dns", "websocket"]
|
||||||
|
|
||||||
|
authors = [
|
||||||
|
{ name = "heartbeat contributors" }
|
||||||
|
]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"websockets>=13.2",
|
||||||
|
"mattermostdriver>=7.3.0",
|
||||||
|
"PyYAML>=6.0",
|
||||||
|
"Jinja2>=3.1.0",s
|
||||||
|
"fastapi>=0.95.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=7.0",
|
||||||
|
"pytest-cov>=4.0",
|
||||||
|
"flake8>=5.0",
|
||||||
|
"mypy>=1.10",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
hbd = "hbd.cli:main"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["."]
|
||||||
|
include = ["hbd*"]
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<footer>
|
||||||
|
<div id="copyright">
|
||||||
|
©2002-2021 <A HREF="mailto:andreas@wrede.ca">Andreas Wrede</A> All Rights Reserved.</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<head>
|
||||||
|
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
|
||||||
|
<link rel="stylesheet" href="/static/style.css" type="text/css" />
|
||||||
|
<link rel="icon" href="/static/images/favicon.ico" sizes="32x32" />
|
||||||
|
<title>{{ title }}</title>
|
||||||
|
<script src="{{ extra_scripts }}"></script>
|
||||||
|
</head>
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
{% include 'head.html' %}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
/* flex: 1; */
|
||||||
|
flex-grow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log {
|
||||||
|
flex: 2;
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#ntable {
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 95%;
|
||||||
|
/* width: 100%; */
|
||||||
|
}
|
||||||
|
|
||||||
|
#ntable td,
|
||||||
|
#ntable th {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ntable tr:nth-child(even) {
|
||||||
|
background-color: #f2f2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ntable tr:hover {
|
||||||
|
background-color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ntable th {
|
||||||
|
padding-top: 12px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
background-color: #9d9d9d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ntable
|
||||||
|
th:not(.sorttable_sorted):not(.sorttable_sorted_reverse):not(.sorttable_nosort):after {
|
||||||
|
content: " \2195";
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script type="text/javascript">
|
||||||
|
var cnt = 0;
|
||||||
|
var nTable = document;
|
||||||
|
var name_idx = {};
|
||||||
|
var c = 0;
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
name_idx = {};
|
||||||
|
nTable = document.getElementById("ntable");
|
||||||
|
for (var i = 0, row; (row = nTable.rows[i]); i++) {
|
||||||
|
if (i == 0) continue;
|
||||||
|
name = nTable.rows[i].cells[0].innerText;
|
||||||
|
name_idx[name] = nTable.rows[i];
|
||||||
|
/* console.log("name_Id[" + name + "]: " + name_idx[name].innerText); */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRow(data) {
|
||||||
|
var row = document.createElement("tr");
|
||||||
|
var c_name = document.createElement("td");
|
||||||
|
var c_ver = document.createElement("td");
|
||||||
|
var c_ipv4addr = document.createElement("td");
|
||||||
|
var c_ipv4state = document.createElement("td");
|
||||||
|
var c_ipv4latency = document.createElement("td");
|
||||||
|
c_ipv4latency.style.textAlign = "right";
|
||||||
|
var c_ipv4statets = document.createElement("td");
|
||||||
|
c_ipv4statets.style.textAlign = "right";
|
||||||
|
var c_ipv6addr = document.createElement("td");
|
||||||
|
var c_ipv6state = document.createElement("td");
|
||||||
|
var c_ipv6latency = document.createElement("td");
|
||||||
|
c_ipv6latency.style.textAlign = "right";
|
||||||
|
var c_ipv6statets = document.createElement("td");
|
||||||
|
c_ipv6statets.style.textAlign = "right";
|
||||||
|
row.appendChild(c_name);
|
||||||
|
row.appendChild(c_ver);
|
||||||
|
row.appendChild(c_ipv4addr);
|
||||||
|
row.appendChild(c_ipv4state);
|
||||||
|
row.appendChild(c_ipv4latency);
|
||||||
|
row.appendChild(c_ipv4statets);
|
||||||
|
row.appendChild(c_ipv6addr);
|
||||||
|
row.appendChild(c_ipv6state);
|
||||||
|
row.appendChild(c_ipv6latency);
|
||||||
|
row.appendChild(c_ipv6statets);
|
||||||
|
if (data.dyn) {
|
||||||
|
c_name.innerHTML = "<b>" + data.name + "</b>";
|
||||||
|
} else {
|
||||||
|
c_name.innerHTML = data.name;
|
||||||
|
}
|
||||||
|
c_ver.innerHTML = data.cver;
|
||||||
|
c_ipv4addr.innerHTML = data.connections[0].addr;
|
||||||
|
c_ipv4state.innerHTML = data.connections[0].state;
|
||||||
|
if (data.connections.length > 1) {
|
||||||
|
c_ipv6addr.innerHTML = data.connections[1].addr;
|
||||||
|
c_ipv6state.innerHTML = data.connections[1].state;
|
||||||
|
}
|
||||||
|
var table = document.getElementById("ntablebody"); // find table to append to
|
||||||
|
table.appendChild(row); // append row to table
|
||||||
|
name_idx[c_name] = row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTS(ts) {
|
||||||
|
const milliseconds = ts * 1000;
|
||||||
|
const dateObject = new Date(milliseconds);
|
||||||
|
return dateObject.toLocaleString("de-DE");
|
||||||
|
}
|
||||||
|
|
||||||
|
function update_table(data) {
|
||||||
|
if (!(data.name in name_idx)) {
|
||||||
|
createRow(data);
|
||||||
|
setup();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < data.connections.length; i++) {
|
||||||
|
name_idx[data.name].cells[2 + i * 4].innerHTML = data.connections[i].addr;
|
||||||
|
name_idx[data.name].cells[5 + i * 4].innerHTML = formatTS(
|
||||||
|
data.connections[i].statetime
|
||||||
|
);
|
||||||
|
if (data.connections[i].state == "up") {
|
||||||
|
state = "up";
|
||||||
|
latency = Number.parseFloat(data.connections[i].rtts[0]).toFixed(2);
|
||||||
|
} else {
|
||||||
|
if (data.connections[i].state == "unknown") {
|
||||||
|
state = "";
|
||||||
|
latency = "";
|
||||||
|
name_idx[data.name].cells[2 + i * 4].innerHTML = "";
|
||||||
|
name_idx[data.name].cells[5 + i * 4].innerHTML = "";
|
||||||
|
} else {
|
||||||
|
state = "<b>" + data.connections[i].state + "</b>";
|
||||||
|
latency = "-";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
name_idx[data.name].cells[3 + i * 4].innerHTML = state;
|
||||||
|
name_idx[data.name].cells[4 + i * 4].innerHTML = latency;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function WS_Connect() {
|
||||||
|
if ("WebSocket" in window) {
|
||||||
|
//N.B: subprotocol field causes chrome to error 1006
|
||||||
|
var ws_hbd = new WebSocket("{{heartbeat_ws_url}}" /*, "hdb" */);
|
||||||
|
|
||||||
|
ws_hbd.onopen = function () {
|
||||||
|
// Web Socket is connected, send data using send()
|
||||||
|
console.log("ws connect");
|
||||||
|
ws_hbd.send("heartbeat_web");
|
||||||
|
};
|
||||||
|
|
||||||
|
ws_hbd.onerror = function (event) {
|
||||||
|
console.log(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws_hbd.onmessage = function (event) {
|
||||||
|
/* console.log(event.data); */
|
||||||
|
var state = JSON.parse(event.data);
|
||||||
|
/* console.log("State: " + state.type); */
|
||||||
|
if (state.type == "host") {
|
||||||
|
update_table(state.data);
|
||||||
|
} else if (state.type == "message") {
|
||||||
|
var msgs = document.getElementById("messages");
|
||||||
|
msgs.insertAdjacentHTML("afterbegin", state.data + "<br>");
|
||||||
|
}
|
||||||
|
cnt++;
|
||||||
|
};
|
||||||
|
|
||||||
|
ws_hbd.onclose = function (event) {
|
||||||
|
/* console.log(event); */
|
||||||
|
console.log("Connection is closed, reopening");
|
||||||
|
setTimeout(function () {
|
||||||
|
WS_Connect();
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// The browser doesn't support WebSocket
|
||||||
|
console.log("WebSocket NOT supported by your Browser!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WS_Connect();
|
||||||
|
</script>
|
||||||
|
<body>
|
||||||
|
{% include 'menu.html' %}
|
||||||
|
|
||||||
|
<div id="content" class="content" style="overflow: hidden">
|
||||||
|
<div id="table" class="table" style="overflow: hidden">
|
||||||
|
<!-- <h2>{{title}}</h2> -->
|
||||||
|
<table id="ntable" class="sortable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Ver</th>
|
||||||
|
<th>IPv4 Addr</th>
|
||||||
|
<th>State</th>
|
||||||
|
<th style="text-align: right">Latencey</th>
|
||||||
|
<th style="text-align: right">Last State</th>
|
||||||
|
<th>IPv6 Addr</th>
|
||||||
|
<th>State</th>
|
||||||
|
<th style="text-align: right">Latencey</th>
|
||||||
|
<th style="text-align: right">Last State</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="ntablebody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="log" class="log" style="overflow: auto;">
|
||||||
|
<h2>Log of Events</h2>
|
||||||
|
<div id="messages">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% include 'foot.html' %}
|
||||||
|
<script>
|
||||||
|
setup();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import time
|
||||||
|
import queue
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
import hbd.dns as dns
|
||||||
|
|
||||||
|
|
||||||
|
class TestDNS(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_create_nsupdate_payload_ipv4(self):
|
||||||
|
p = dns.create_nsupdate_payload("host", "1.2.3.4", "example")
|
||||||
|
self.assertIn("update add host.dy.example 5 A 1.2.3.4", p)
|
||||||
|
self.assertNotIn("AAAA", p)
|
||||||
|
|
||||||
|
def test_create_nsupdate_payload_ipv6(self):
|
||||||
|
p = dns.create_nsupdate_payload("host", "2001:db8::1", "example")
|
||||||
|
self.assertIn("update add host.dy.example 5 AAAA 2001:db8::1", p)
|
||||||
|
|
||||||
|
@patch("hbd.dns.Popen")
|
||||||
|
def test_nsupdate_success(self, mock_popen):
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.communicate.return_value = (b"status: NOERROR", None)
|
||||||
|
mock_popen.return_value = proc
|
||||||
|
|
||||||
|
err = dns.nsupdate(
|
||||||
|
"host",
|
||||||
|
"1.2.3.4",
|
||||||
|
"example",
|
||||||
|
nsupdate_bin="/usr/bin/nsupdate",
|
||||||
|
rndc_key="/etc/rndc.key",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNone(err)
|
||||||
|
mock_popen.assert_called_once_with(
|
||||||
|
["/usr/bin/nsupdate", "-k", "/etc/rndc.key", "-v"],
|
||||||
|
shell=False,
|
||||||
|
bufsize=0,
|
||||||
|
stdin=dns.PIPE,
|
||||||
|
stdout=dns.PIPE,
|
||||||
|
stderr=dns.STDOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("hbd.dns.Popen")
|
||||||
|
def test_nsupdate_failure(self, mock_popen):
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.communicate.return_value = (b"some error", None)
|
||||||
|
mock_popen.return_value = proc
|
||||||
|
|
||||||
|
err = dns.nsupdate("host", "1.2.3.4", "example", nsupdate_bin="/usr/bin/nsupdate", rndc_key="/etc/rndc.key")
|
||||||
|
self.assertIsNotNone(err)
|
||||||
|
self.assertIn("some error", err)
|
||||||
|
|
||||||
|
def test_dnsupdatethread_processes_queue(self):
|
||||||
|
# patch nsupdate to succeed
|
||||||
|
with patch("hbd.dns.nsupdate", return_value=None):
|
||||||
|
logs = []
|
||||||
|
|
||||||
|
def log(h, m):
|
||||||
|
logs.append((h, m))
|
||||||
|
|
||||||
|
emails = []
|
||||||
|
|
||||||
|
def email(s, m):
|
||||||
|
emails.append((s, m))
|
||||||
|
|
||||||
|
class FakeHost:
|
||||||
|
dnsQ = queue.Queue()
|
||||||
|
|
||||||
|
class FakeHbd:
|
||||||
|
Host = FakeHost
|
||||||
|
|
||||||
|
# start the thread (daemon) that processes the queue
|
||||||
|
t = dns.start_dns_thread(FakeHbd, {"dyndomains": ["example"]}, log=log, email=email)
|
||||||
|
self.assertTrue(t.is_alive())
|
||||||
|
|
||||||
|
# enqueue one item and wait for it to be processed (polling with timeout)
|
||||||
|
FakeHbd.Host.dnsQ.put(("testhost", "1.2.3.4"))
|
||||||
|
|
||||||
|
for _ in range(30):
|
||||||
|
if logs:
|
||||||
|
break
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
self.assertTrue(logs, "dnsupdatethread did not call log")
|
||||||
|
self.assertTrue(any("changed address" in m or "DNS updated" in m for (_h, m) in logs))
|
||||||
|
|
||||||
|
def test_dnsupdatethread_calls_email_on_failure(self):
|
||||||
|
# patch nsupdate to fail with an error message
|
||||||
|
with patch("hbd.dns.nsupdate", return_value="error: failed"):
|
||||||
|
logs = []
|
||||||
|
|
||||||
|
def log(h, m):
|
||||||
|
logs.append((h, m))
|
||||||
|
|
||||||
|
emails = []
|
||||||
|
|
||||||
|
def email(s, m):
|
||||||
|
emails.append((s, m))
|
||||||
|
|
||||||
|
class FakeHost:
|
||||||
|
dnsQ = queue.Queue()
|
||||||
|
|
||||||
|
class FakeHbd:
|
||||||
|
Host = FakeHost
|
||||||
|
|
||||||
|
t = dns.start_dns_thread(FakeHbd, {"dyndomains": ["example"]}, log=log, email=email)
|
||||||
|
# enqueue and wait for the email to be sent
|
||||||
|
FakeHbd.Host.dnsQ.put(("testhost", "1.2.3.4"))
|
||||||
|
|
||||||
|
for _ in range(30):
|
||||||
|
if emails:
|
||||||
|
break
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
self.assertTrue(emails, "dnsupdatethread did not call email on failure")
|
||||||
|
self.assertTrue(any("nsupdate failed" in s or "nsupdate failed" in m or "error" in m for (s, m) in emails))
|
||||||
|
|
||||||
|
@patch("hbd.dns.Popen")
|
||||||
|
def test_nsupdate_raises_oserror(self, mock_popen):
|
||||||
|
mock_popen.side_effect = OSError("noexec")
|
||||||
|
err = dns.nsupdate("h", "1.2.3.4", "example", nsupdate_bin="/usr/bin/nsupdate", rndc_key="/etc/rndc.key")
|
||||||
|
self.assertIsNotNone(err)
|
||||||
|
self.assertIn("execution failed", err)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from hbd.udp import handle_datagram, parse_message
|
||||||
|
from hbd.proto import dicttos
|
||||||
|
|
||||||
|
class FakeTransport:
|
||||||
|
def __init__(self):
|
||||||
|
self.sent = []
|
||||||
|
def sendto(self, data, addr):
|
||||||
|
self.sent.append((data, addr))
|
||||||
|
|
||||||
|
|
||||||
|
def dummy_noop(*a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_cmd_sends_command():
|
||||||
|
ftr = FakeTransport()
|
||||||
|
# prepare ctx linking to hbdclass
|
||||||
|
import hbdclass
|
||||||
|
|
||||||
|
ctx = {
|
||||||
|
'config': {'watchhosts':[], 'dyndnshosts':[]},
|
||||||
|
'hbdclass': hbdclass,
|
||||||
|
'log': dummy_noop,
|
||||||
|
'email': dummy_noop,
|
||||||
|
'pushmsg': dummy_noop,
|
||||||
|
'msg_to_websockets': dummy_noop,
|
||||||
|
'msgs': [],
|
||||||
|
'DEBUG': 0,
|
||||||
|
'verbose': False,
|
||||||
|
}
|
||||||
|
|
||||||
|
# create host by sending initial heartbeat
|
||||||
|
msg = parse_message(dicttos('HTB', {'name':'cmdhost','interval':10}))
|
||||||
|
handle_datagram(msg, ('127.0.0.1',50000), ftr, ctx)
|
||||||
|
assert ftr.sent[0][0] == b'ACK'
|
||||||
|
|
||||||
|
# queue a CMD for the host and send another heartbeat; expect command sent
|
||||||
|
h = hbdclass.Host.hosts['cmdhost']
|
||||||
|
h.cmds.append(('CMD', {'cmd': 'doit'}))
|
||||||
|
ftr.sent.clear()
|
||||||
|
msg2 = parse_message(dicttos('HTB', {'name':'cmdhost','interval':10}))
|
||||||
|
handle_datagram(msg2, ('127.0.0.1',50000), ftr, ctx)
|
||||||
|
# should have sent ACK and the command; last send should be non-empty
|
||||||
|
assert len(ftr.sent) >= 1
|
||||||
|
# the command for cver 0 will be sent as raw cmd string
|
||||||
|
# so at least one send contains b'doit' or similar
|
||||||
|
assert any(b'doit' in s[0] for s in ftr.sent)
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import pytest
|
||||||
|
from hbd.proto import dicttos, stodict, oldmtodict
|
||||||
|
|
||||||
|
|
||||||
|
def test_dicttos_and_stodict_uncompressed():
|
||||||
|
msg = dicttos("HTB", {"name": "host.example", "interval": 10})
|
||||||
|
d = stodict(msg)
|
||||||
|
assert d["ID"].startswith("HTB")
|
||||||
|
assert d["name"] == "host.example"
|
||||||
|
assert d["interval"] == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_dicttos_and_stodict_compressed():
|
||||||
|
msg = dicttos("ACK", {"time": 12345}, compress=True)
|
||||||
|
d = stodict(msg)
|
||||||
|
# for compressed the original code included the colon in ID slice; ensure no crash
|
||||||
|
assert "ID" in d
|
||||||
|
|
||||||
|
|
||||||
|
def test_oldmtodict():
|
||||||
|
msg = b"name=foo;interval=5"
|
||||||
|
d = oldmtodict(msg)
|
||||||
|
assert d["ID"].startswith("HTB")
|
||||||
|
assert d["name"] == "foo"
|
||||||
|
assert d["interval"] == 5
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from hbd.udp import parse_message
|
||||||
|
from hbd.proto import dicttos
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_message_uncompressed():
|
||||||
|
raw = dicttos('HTB', {'name': 'host', 'interval': 1})
|
||||||
|
m = parse_message(raw)
|
||||||
|
assert m['ID'].startswith('HTB')
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_message_compressed():
|
||||||
|
raw = dicttos('ACK', {'time': 1}, compress=True)
|
||||||
|
m = parse_message(raw)
|
||||||
|
assert 'ID' in m
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
[tox]
|
||||||
|
envlist = py, lint, mypy
|
||||||
|
skipsdist = True
|
||||||
|
|
||||||
|
[testenv]
|
||||||
|
deps = -rrequirements-dev.txt
|
||||||
|
commands =
|
||||||
|
pytest -q
|
||||||
|
|
||||||
|
[testenv:lint]
|
||||||
|
description = run linters
|
||||||
|
deps =
|
||||||
|
flake8>=5.0
|
||||||
|
commands =
|
||||||
|
flake8 hbd tests
|
||||||
|
|
||||||
|
[testenv:mypy]
|
||||||
|
description = run mypy type checks
|
||||||
|
deps =
|
||||||
|
mypy>=1.10
|
||||||
|
commands =
|
||||||
|
mypy hbd
|
||||||
|
|
||||||
|
[flake8]
|
||||||
|
max-line-length = 88
|
||||||
|
extend-ignore = E203
|
||||||
Reference in New Issue
Block a user