#!/usr/bin/env python3 """taler-monitord — probe domain stacks on an interval; UI reads JSON only.""" from __future__ import annotations import argparse import os import subprocess import sys import tempfile import time from pathlib import Path HERE = Path(__file__).resolve().parent sys.path.insert(0, str(HERE)) import stack_status_html as H # noqa: E402 import stack_status_lib as L # noqa: E402 # Match lib.sh: load ~/.config/taler-monitoring/env (or TALER_MONITORING_ENV), then # refresh module flags that were read at import time. _ENV_LOADED = L.load_monitoring_env() if _ENV_LOADED: L.refresh_runtime_flags_from_env() def _pid_path() -> Path: return L.monitord_state_dir() / "taler-monitord.pid" def _write_pid() -> None: _pid_path().write_text(str(os.getpid()) + "\n", encoding="utf-8") def _clear_pid() -> None: p = _pid_path() try: p.unlink() except OSError: pass def _publish_one(local: Path, ssh_host: str, remote: str) -> str | None: remote_dir = str(Path(remote).parent) mk = subprocess.run( [ "ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", ssh_host, f"mkdir -p -- {remote_dir!s}", ], capture_output=True, timeout=15, check=False, ) if mk.returncode != 0: err = (mk.stderr or b"").decode("utf-8", "replace").strip()[:200] print(f"publish mkdir fail ({remote}): {err}", file=sys.stderr, flush=True) return None r = subprocess.run( [ "scp", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", str(local), f"{ssh_host}:{remote}", ], capture_output=True, timeout=30, check=False, ) if r.returncode != 0: err = (r.stderr or b"").decode("utf-8", "replace").strip()[:200] print(f"publish fail ({remote}): {err}", file=sys.stderr, flush=True) return None if "/var/www/monitoring-sites/" in remote: subprocess.run( [ "ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", ssh_host, ( f"chmod 755 -- {remote_dir!s} && chmod 644 -- {remote!s} && " f"setfacl -m u:caddy:rx,m::r-x -- {remote_dir!s} && " f"setfacl -m u:caddy:r,m::r-- -- {remote!s} || true" ), ], capture_output=True, timeout=15, check=False, ) else: subprocess.run( [ "ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", ssh_host, f"chmod 755 -- {remote_dir!s} && chmod 644 -- {remote!s} || true", ], capture_output=True, timeout=15, check=False, ) return f"{ssh_host}:{remote}" def publish_html(doc: dict) -> str | None: """Render HTML and scp to Cyon (hacktivism) paths. Env: TALER_MONITORD_PUBLISH=0 to disable TALER_MONITORD_PUBLISH_SSH=hacktivism TALER_MONITORD_PUBLISH_PATH=… single path (overrides multi default) TALER_MONITORD_PUBLISH_PATHS=comma-separated paths Default paths: public_html/stacks + public_html/monitoring.hacktivism.ch """ if os.environ.get("TALER_MONITORD_PUBLISH", "1") in ("0", "false", "no", ""): return None ssh_host = os.environ.get("TALER_MONITORD_PUBLISH_SSH", "hacktivism").strip() or "hacktivism" single = (os.environ.get("TALER_MONITORD_PUBLISH_PATH") or "").strip() multi = (os.environ.get("TALER_MONITORD_PUBLISH_PATHS") or "").strip() if single: remotes = [single] elif multi: remotes = [p.strip() for p in multi.split(",") if p.strip()] else: remotes = [ "/home/hacktivi/public_html/stacks/index.html", "/home/hacktivi/public_html/monitoring.hacktivism.ch/index.html", ] html_text = H.render_html(doc) with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".html", delete=False) as f: f.write(html_text) local = Path(f.name) published: list[str] = [] try: for remote in remotes: got = _publish_one(local, ssh_host, remote) if got: published.append(got) return "; ".join(published) if published else None except (OSError, subprocess.TimeoutExpired) as e: print(f"publish error: {e}", file=sys.stderr, flush=True) return None finally: try: local.unlink() except OSError: pass def run_once(*, domains: Path, out_paths: list[Path] | None, public_only: bool) -> Path: stacks = L.parse_domains_conf(domains) prev = L.read_status(out_paths[0] if out_paths else None) if not prev and out_paths: for p in out_paths: prev = L.read_status(p) if prev: break doc = L.build_stack_status(stacks, prev_doc=prev, public_only=public_only) paths = out_paths or L.status_json_paths() path = L.write_stack_status(doc, paths) # always keep a local HTML copy too local_html = L.monitord_state_dir() / "index.html" local_html.write_text(H.render_html(doc), encoding="utf-8") published = publish_html(doc) if published: print(f"published {published}", flush=True) return path def main() -> int: ap = argparse.ArgumentParser(description="Collect Taler stack status for the detached UI") ap.add_argument("--interval", type=float, default=float(os.environ.get("TALER_MONITORD_INTERVAL", "20"))) ap.add_argument("--once", action="store_true", help="Single probe cycle then exit") ap.add_argument("--domains-conf", type=Path, default=None) ap.add_argument("--out", type=Path, action="append", default=None, help="Output JSON path (repeatable)") # Default on: include GOA / hacktivism.ch (taler.hacktivism.ch merchant). Opt out: --no-include-local _incl = os.environ.get("TALER_MONITORD_INCLUDE_LOCAL", "1") not in ("0", "false", "no", "") ap.add_argument( "--include-local", action=argparse.BooleanOptionalAction, default=_incl, help="Probe local=1 stacks (hacktivism GOA). Default on; --no-include-local to skip", ) args = ap.parse_args() if _ENV_LOADED: print(f"env {_ENV_LOADED}", flush=True) domains = args.domains_conf or L.default_domains_conf() if not domains.is_file(): print(f"domains.conf not found: {domains}", file=sys.stderr) return 2 public_only = not bool(args.include_local) out_paths = list(args.out) if args.out else None if args.once: path = run_once(domains=domains, out_paths=out_paths, public_only=public_only) print(path) return 0 _write_pid() try: while True: try: path = run_once(domains=domains, out_paths=out_paths, public_only=public_only) print(f"ok {path}", flush=True) except Exception as e: # keep loop alive print(f"error {type(e).__name__}: {e}", file=sys.stderr, flush=True) time.sleep(max(1.0, float(args.interval))) finally: _clear_pid() if __name__ == "__main__": raise SystemExit(main())