diff options
-rw-r--r-- | .vscode/launch.json | 10 | ||||
-rw-r--r-- | src/conf/palhm-boot-report.service | 14 | ||||
-rw-r--r-- | src/conf/py-sample/boot-report.jsonc | 7 | ||||
-rwxr-xr-x | src/palhm-dnssec-check.sh | 9 | ||||
-rwxr-xr-x | src/palhm.py | 32 | ||||
-rw-r--r-- | src/palhm/__init__.py | 129 |
6 files changed, 188 insertions, 13 deletions
diff --git a/.vscode/launch.json b/.vscode/launch.json index 2d8e2c8..5ed76e2 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -25,6 +25,16 @@ "justMyCode": true }, { + "name": "palhm boot-report", + "type": "python", + "request": "launch", + "cwd": "${workspaceFolder}", + "program": "src/palhm.py", + "args": [ "-f", "src/conf/py-debug/palhm.jsonc", "boot-report" ], + "console": "integratedTerminal", + "justMyCode": true + }, + { "name": "palhm run default", "type": "python", "request": "launch", diff --git a/src/conf/palhm-boot-report.service b/src/conf/palhm-boot-report.service new file mode 100644 index 0000000..288aabd --- /dev/null +++ b/src/conf/palhm-boot-report.service @@ -0,0 +1,14 @@ +[Unit] +Description=PALHM send boot report mail +After=postfix.service sendmail.service exim.service + +[Service] +Type=oneshot +ExecStart=/var/lib/PALHM/src/palhm.py -q boot-report +Nice=10 +ProtectSystem=strict +ReadOnlyPaths=/ +PrivateDevices=true + +[Install] +WantedBy=multi-user.target diff --git a/src/conf/py-sample/boot-report.jsonc b/src/conf/py-sample/boot-report.jsonc new file mode 100644 index 0000000..dd9d606 --- /dev/null +++ b/src/conf/py-sample/boot-report.jsonc @@ -0,0 +1,7 @@ +{ + "boot-report": { + // "mua": "stdout", + "mua": "mailx", + "mail-to": [ "root" ] + } +} diff --git a/src/palhm-dnssec-check.sh b/src/palhm-dnssec-check.sh index f5ee466..4601d8e 100755 --- a/src/palhm-dnssec-check.sh +++ b/src/palhm-dnssec-check.sh @@ -1,7 +1,4 @@ #!/bin/bash -set -e -. "$( dirname -- "${BASH_SOURCE[0]}" )"/common.sh - do_query () { # dig returns 0 upon successful reception and parse of the response message. # All the other exit codes other than 0 will cause the script to terminate @@ -11,9 +8,9 @@ do_query () { # record will also return nothing with the status code zero. dig +short +dnssec ANY "$TARGET" > "$tmpf" if [ ! -s "$tmpf" ]; then - palhm_die \ - "The nameserver returned no RR! -DNSSEC verification probably failed." + echo "The nameserver returned no RR! +DNSSEC verification probably failed." >&2 + exit 1 fi } diff --git a/src/palhm.py b/src/palhm.py index 474f0a0..f008736 100755 --- a/src/palhm.py +++ b/src/palhm.py @@ -7,6 +7,7 @@ from abc import ABC, abstractmethod from getopt import getopt import palhm +from palhm.exceptions import InvalidConfigError class ProgConf: @@ -113,6 +114,23 @@ class ModsCmd (Cmd): "Usage: " + sys.argv[0] + " mods" + ''' Prints the available modules to stdout.''') +class BootReportCmd (Cmd): + def __init__ (self, *args, **kwargs): + pass + + def do_cmd (self): + ProgConf.alloc_ctx() + + if ProgConf.ctx.boot_report is None: + raise InvalidConfigError("'boot-report' not configured") + + return ProgConf.ctx.boot_report.do_send(ProgConf.ctx) + + def print_help (): + print( +"Usage: " + sys.argv[0] + " boot-report" + ''' +Send mail of boot report to recipients configured.''') + class HelpCmd (Cmd): def __init__ (self, optlist, args): self.optlist = optlist @@ -138,11 +156,12 @@ Options: -f FILE Load config from FILE instead of the hard-coded default Config: ''' + ProgConf.conf + ''' Commands: - run run a task - config load config and print the contents - help [CMD] print this message and exit normally if [CMD] is not specified. - Print usage of [CMD] otherwise - mods list available modules''') + run run a task + config load config and print the contents + help [CMD] print this message and exit normally if [CMD] is not specified. + Print usage of [CMD] otherwise + mods list available modules + boot-report mail boot report''') return 0 @@ -150,7 +169,8 @@ CmdMap = { "config": ConfigCmd, "run": RunCmd, "help": HelpCmd, - "mods": ModsCmd + "mods": ModsCmd, + "boot-report": BootReportCmd } optlist, args = getopt(sys.argv[1:], "qvf:") diff --git a/src/palhm/__init__.py b/src/palhm/__init__.py index c227a3e..e34e58a 100644 --- a/src/palhm/__init__.py +++ b/src/palhm/__init__.py @@ -1,3 +1,8 @@ +import platform +import sys +import time + +import yaml from .exceptions import InvalidConfigError import io import json @@ -64,6 +69,10 @@ class GlobalContext: self.l = logging.getLogger("palhm") self.l.setLevel(self.vl) + self.boot_report = ( + BootReport(jobj["boot-report"]) if "boot-report" in jobj + else None) + if self.nb_workers == 0: self.nb_workers = DEFAULT.NB_WORKERS.value elif self.nb_workers < 0: @@ -104,6 +113,120 @@ class GlobalContext: ("task_map:\n" + "\n".join([ (i[0] + ":\n" + str(i[1])).replace("\n", "\n\t") for i in self.task_map.items() ])).replace("\n", "\n\t") ]).replace("\t", " ") +class BootReport: + def _hostname () -> str: + return platform.node() + + def _do_format (x: str) -> str: + return x.format( + hostname = BootReport._hostname() + ) + + def _default_subject () -> str: + return "Boot Report from {hostname}" + + def _fmt_yaml_comment_header (x: str) -> str: + ret = list[str]() + + for i in x.splitlines(): + ret.append("# " + i) + + return "\n".join(ret) + + def _default_header () -> str: + return ( + "This is a boot report from {hostname}.\n" + + "More details as follows.") + + def __init__ (self, jobj: dict): + mua = jobj["mua"] + if mua == "mailx": self._mua_f = self._do_send_mailx + elif mua == "stdout": self._mua_f = self._do_send_stdout + else: raise InvalidConfigError("Unsupported MUA", mua) + + self.recipients = jobj["mail-to"] + self.subject = jobj.get("subject", BootReport._default_subject()) + self.header = jobj.get("header", BootReport._default_header()) + self.uptime_since = jobj.get("uptime-since", True) + self.uptime = jobj.get("uptime", True) + self.bootid = jobj.get("boot-id", True) + self.systemd_analyze = jobj.get("systemd-analyze", True) + + def get_subject (self) -> str: + return BootReport._do_format(self.subject) + + def compose_body (self, ctx: GlobalContext): + body = {} + root_doc = { "boot-report": body } + + yield BootReport._fmt_yaml_comment_header( + BootReport._do_format(self.header)) + "\n" + + body["hostname"] = BootReport._hostname() + body["tz"] = list(time.tzname) + [time.timezone] + + if self.uptime_since: + p = subprocess.run( + [ "/bin/uptime", "--since" ], + stdin = subprocess.DEVNULL, + capture_output = True) + if p.returncode != 0: + raise ChildProcessError("uptime-since", p.returncode) + body["uptime-since"] = p.stdout.decode().strip() + + if self.uptime: + p = subprocess.run( + [ "/bin/uptime", "-p" ], + stdin = subprocess.DEVNULL, + capture_output = True) + if p.returncode != 0: + raise ChildProcessError("uptime", p.returncode) + body["uptime"] = p.stdout.decode().strip() + + if self.bootid: + with open("/proc/sys/kernel/random/boot_id") as f: + body["bood-id"] = f.readline(36) + + if self.systemd_analyze: + p = subprocess.run( + [ "/bin/systemd-analyze" ], + stdin = subprocess.DEVNULL, + capture_output = True) + if p.returncode != 0: + raise ChildProcessError("systemd-analyze", p.returncode) + body["systemd-analyze"] = p.stdout.decode().strip() + + yield yaml.dump(root_doc) + + def do_send (self, ctx: GlobalContext) -> int: + return self._mua_f(ctx) + + def _do_send_mailx (self, ctx: GlobalContext) -> int: + argv = [ "/bin/mailx", "-s", self.get_subject() ] + self.recipients + + with subprocess.Popen( + argv, + stdin = subprocess.PIPE, + stdout = subprocess.DEVNULL, + stderr = subprocess.PIPE) as p: + for d in self.compose_body(ctx): + p.stdin.write(d.encode()) + p.stdin.close() + + return p.wait() + + def _do_send_stdout (self, ctx: GlobalContext) -> int: + sys.stdout.write(self.get_subject() + "\n") + + for r in self.recipients: + sys.stdout.write(r + "\n") + sys.stdout.write("\n") + + for d in self.compose_body(ctx): + sys.stdout.write(d) + + return 0 + class Runnable (ABC): @abstractmethod def run (self, ctx: GlobalContext): @@ -710,6 +833,10 @@ def merge_conf (a: dict, b: dict) -> dict: c = chk_dup_id("tasks", a, b) if c: raise KeyError("Dup tasks", c) + # "boot-report" conflict + if "boot-report" in a and "boot-report" in b: + raise InvalidConfigError( + "'boot-report' already defined in the previous config") ret = a | b ret["execs"] = a.get("execs", []) + b.get("execs", []) @@ -764,7 +891,7 @@ def setup_conf (jobj: dict) -> GlobalContext: for i in jobj.get("execs", iter(())): ret.exec_map[i["id"]] = Exec(i) - for i in jobj["tasks"]: + for i in jobj.get("tasks", iter(())): ret.task_map[i["id"]] = TaskClassMap[i["type"]](ret, i) return ret |