#!/usr/bin/env python3 """ monitor.py — Lightweight uptime / latency / SSL monitor. Standard-library only (urllib, ssl, socket, json, time). Runs an infinite loop, checks a target URL (and optionally a search endpoint) every CHECK_INTERVAL seconds, and persists a rolling RETENTION_HOURS window of results to a JSON file that the static dashboard reads. Configuration is via environment variables so the same image can monitor any site without a rebuild: TARGET_URL Full URL to monitor (default: https://mithal.space) SEARCH_PATH Path appended to origin for a (default: /search?q=test) secondary "search" check. Set to "" to disable. CHECK_INTERVAL Seconds between checks (default: 60) RETENTION_HOURS How much history to keep (default: 24) REQUEST_TIMEOUT Per-request timeout, seconds (default: 10) DATA_FILE Where to write the JSON log (default: /app/web/data/metrics.json) """ import json import os import socket import ssl import sys import time import urllib.error import urllib.request from datetime import datetime, timezone from urllib.parse import urlparse # -------------------------------------------------------------------------- # Configuration # -------------------------------------------------------------------------- TARGET_URL = os.environ.get("TARGET_URL", "https://mithal.space") SEARCH_PATH = os.environ.get("SEARCH_PATH", "/search?q=test") CHECK_INTERVAL = int(os.environ.get("CHECK_INTERVAL", "60")) RETENTION_HOURS = float(os.environ.get("RETENTION_HOURS", "24")) REQUEST_TIMEOUT = float(os.environ.get("REQUEST_TIMEOUT", "10")) DATA_FILE = os.environ.get("DATA_FILE", "/app/web/data/metrics.json") USER_AGENT = "uptime-monitor/1.0 (+standard-library)" _parsed = urlparse(TARGET_URL) HOSTNAME = _parsed.hostname PORT = _parsed.port or (443 if _parsed.scheme == "https" else 80) SEARCH_URL = f"{_parsed.scheme}://{_parsed.netloc}{SEARCH_PATH}" if SEARCH_PATH else None def now_iso() -> str: return datetime.now(timezone.utc).isoformat(timespec="seconds") def measure_dns(hostname: str): """Return DNS resolution time in milliseconds, or None on failure.""" start = time.perf_counter() try: socket.getaddrinfo(hostname, None) except socket.gaierror as exc: return None, str(exc) elapsed_ms = (time.perf_counter() - start) * 1000 return round(elapsed_ms, 2), None def timed_get(url: str, timeout: float): """ Perform an HTTP GET and return (status_code, latency_ms, error_str). latency_ms measures time-to-first-byte-of-full-response (connect + TLS + request + response), matching what a real visitor experiences. """ req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) start = time.perf_counter() try: with urllib.request.urlopen(req, timeout=timeout) as resp: resp.read(1) # confirm the body actually starts streaming status = resp.status latency_ms = (time.perf_counter() - start) * 1000 return status, round(latency_ms, 2), None except urllib.error.HTTPError as exc: # Still a "successful" connection from a monitoring standpoint — # the server responded, just with an error status. latency_ms = (time.perf_counter() - start) * 1000 return exc.code, round(latency_ms, 2), None except (urllib.error.URLError, socket.timeout, TimeoutError) as exc: latency_ms = (time.perf_counter() - start) * 1000 return None, round(latency_ms, 2), str(getattr(exc, "reason", exc)) def measure_ssl_expiry(hostname: str, port: int, timeout: float): """Return (days_remaining, error_str) for the TLS certificate.""" if not hostname: return None, "no hostname" try: ctx = ssl.create_default_context() with socket.create_connection((hostname, port), timeout=timeout) as sock: with ctx.wrap_socket(sock, server_hostname=hostname) as ssock: cert = ssock.getpeercert() not_after = cert.get("notAfter") if not not_after: return None, "no notAfter field in certificate" expiry_dt = datetime.strptime(not_after, "%b %d %H:%M:%S %Y %Z").replace( tzinfo=timezone.utc ) days_remaining = (expiry_dt - datetime.now(timezone.utc)).total_seconds() / 86400 return round(days_remaining, 1), None except Exception as exc: # noqa: BLE001 — monitoring must never crash the loop return None, str(exc) def run_check() -> dict: dns_ms, dns_err = measure_dns(HOSTNAME) status, latency_ms, http_err = timed_get(TARGET_URL, REQUEST_TIMEOUT) search_status, search_latency_ms, search_err = None, None, None if SEARCH_URL: search_status, search_latency_ms, search_err = timed_get(SEARCH_URL, REQUEST_TIMEOUT) ssl_days, ssl_err = (None, None) if _parsed.scheme == "https": ssl_days, ssl_err = measure_ssl_expiry(HOSTNAME, PORT, REQUEST_TIMEOUT) success = status is not None and 200 <= status < 400 return { "timestamp": now_iso(), "target": TARGET_URL, "http_status": status, "success": success, "latency_ms": latency_ms, "dns_ms": dns_ms, "search_status": search_status, "search_latency_ms": search_latency_ms, "ssl_days_remaining": ssl_days, "error": http_err or dns_err or ssl_err or search_err, } def load_history(path: str) -> list: try: with open(path, "r", encoding="utf-8") as f: data = json.load(f) return data.get("checks", []) if isinstance(data, dict) else data except (FileNotFoundError, json.JSONDecodeError): return [] def prune(history: list, retention_hours: float) -> list: cutoff = time.time() - retention_hours * 3600 pruned = [] for entry in history: try: ts = datetime.fromisoformat(entry["timestamp"]).timestamp() except (KeyError, ValueError): continue if ts >= cutoff: pruned.append(entry) return pruned def save_history(path: str, history: list) -> None: os.makedirs(os.path.dirname(path), exist_ok=True) payload = { "target": TARGET_URL, "search_url": SEARCH_URL, "updated_at": now_iso(), "check_interval_seconds": CHECK_INTERVAL, "retention_hours": RETENTION_HOURS, "checks": history, } tmp_path = f"{path}.tmp" with open(tmp_path, "w", encoding="utf-8") as f: json.dump(payload, f, indent=2) os.replace(tmp_path, path) # atomic write so the dashboard never reads a half-written file def main() -> None: print(f"[monitor] target={TARGET_URL} interval={CHECK_INTERVAL}s " f"retention={RETENTION_HOURS}h data_file={DATA_FILE}", flush=True) history = load_history(DATA_FILE) while True: cycle_start = time.time() result = run_check() history.append(result) history = prune(history, RETENTION_HOURS) save_history(DATA_FILE, history) status_str = result["http_status"] if result["http_status"] is not None else "ERR" print( f"[monitor] {result['timestamp']} status={status_str} " f"latency={result['latency_ms']}ms dns={result['dns_ms']}ms " f"ssl_days={result['ssl_days_remaining']} " f"search_latency={result['search_latency_ms']}ms " f"{'error=' + result['error'] if result['error'] else 'ok'}", flush=True, ) elapsed = time.time() - cycle_start time.sleep(max(0, CHECK_INTERVAL - elapsed)) if __name__ == "__main__": try: main() except KeyboardInterrupt: sys.exit(0)