certminder 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
certminder/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """certminder: continuous TLS certificate monitoring built on top of certinspect."""
2
+
3
+ __version__ = "0.2.0"
certminder/cli.py ADDED
@@ -0,0 +1,100 @@
1
+ """Command-line entry point for certminder.
2
+
3
+ Subcommands:
4
+ once run a single inspection cycle and exit (ideal for cron)
5
+ run run continuously, sleeping ``interval`` between cycles (daemon)
6
+ check inspect a single host ad hoc, ignoring the config's targets
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import sys
14
+
15
+ from certminder import __version__
16
+ from certminder.config import Config, ConfigError, load_config
17
+ from certminder.engine import check_target
18
+ from certminder.models import Target
19
+ from certminder.scheduler import run_loop, run_once
20
+
21
+
22
+ def build_parser() -> argparse.ArgumentParser:
23
+ parser = argparse.ArgumentParser(
24
+ prog="certminder",
25
+ description="Continuously monitor TLS certificates and alert on changes.",
26
+ )
27
+ parser.add_argument(
28
+ "--version", action="version", version=f"%(prog)s {__version__}"
29
+ )
30
+ sub = parser.add_subparsers(dest="command", required=True)
31
+
32
+ p_once = sub.add_parser("once", help="run a single inspection cycle and exit")
33
+ p_once.add_argument("-c", "--config", required=True, help="path to certminder.yml")
34
+ p_once.add_argument(
35
+ "--json",
36
+ action="store_true",
37
+ help="print a JSON summary of the cycle to stdout",
38
+ )
39
+
40
+ p_run = sub.add_parser("run", help="run continuously as a daemon")
41
+ p_run.add_argument("-c", "--config", required=True, help="path to certminder.yml")
42
+
43
+ p_check = sub.add_parser("check", help="inspect one host ad hoc")
44
+ p_check.add_argument("host")
45
+ p_check.add_argument("--port", type=int, default=443)
46
+ p_check.add_argument("--no-verify", action="store_true")
47
+ p_check.add_argument("--starttls")
48
+ p_check.add_argument("--bin", default="certinspect", help="certinspect path")
49
+
50
+ return parser
51
+
52
+
53
+ def _cmd_check(args: argparse.Namespace) -> int:
54
+ target = Target(
55
+ host=args.host,
56
+ port=args.port,
57
+ verify=not args.no_verify,
58
+ starttls=args.starttls,
59
+ )
60
+ result = check_target(target, args.bin)
61
+ icon = "ok" if result.status == "VALID" else result.status
62
+ detail = (
63
+ f"{result.days_to_expire} day(s) left"
64
+ if result.days_to_expire is not None
65
+ else (result.error or "")
66
+ )
67
+ print(f"{target.name}: {icon} ({detail})")
68
+ return 0 if result.status == "VALID" else 1
69
+
70
+
71
+ def main(argv: list[str] | None = None) -> int:
72
+ args = build_parser().parse_args(argv)
73
+
74
+ if args.command == "check":
75
+ return _cmd_check(args)
76
+
77
+ try:
78
+ config: Config = load_config(args.config)
79
+ except ConfigError as exc:
80
+ print(f"certminder: {exc}", file=sys.stderr)
81
+ return 2
82
+
83
+ if args.command == "once":
84
+ report = run_once(config)
85
+ if args.json:
86
+ print(json.dumps(report.to_dict(), indent=2))
87
+ return 1 if report.events else 0
88
+
89
+ if args.command == "run":
90
+ try:
91
+ run_loop(config)
92
+ except KeyboardInterrupt: # pragma: no cover
93
+ print("certminder: stopped", file=sys.stderr)
94
+ return 0
95
+
96
+ return 2 # pragma: no cover
97
+
98
+
99
+ if __name__ == "__main__": # pragma: no cover
100
+ raise SystemExit(main())
certminder/config.py ADDED
@@ -0,0 +1,120 @@
1
+ """Load and validate the YAML configuration into typed objects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import yaml
11
+
12
+ from certminder.models import Target
13
+
14
+ _DURATION_RE = re.compile(r"^\s*(\d+)\s*([smhd])\s*$", re.IGNORECASE)
15
+ _UNIT_SECONDS = {"s": 1, "m": 60, "h": 3600, "d": 86400}
16
+
17
+
18
+ class ConfigError(ValueError):
19
+ """Raised when the configuration file is missing or malformed."""
20
+
21
+
22
+ def parse_duration(value: str | int) -> int:
23
+ """Convert a duration like '6h', '30m', '1d' (or an int) into seconds."""
24
+ if isinstance(value, int):
25
+ return value
26
+ match = _DURATION_RE.match(str(value))
27
+ if not match:
28
+ raise ConfigError(
29
+ f"invalid duration {value!r}; use a number with s/m/h/d (e.g. 6h)"
30
+ )
31
+ amount, unit = match.groups()
32
+ return int(amount) * _UNIT_SECONDS[unit.lower()]
33
+
34
+
35
+ @dataclass
36
+ class NotifierConfig:
37
+ """Raw notifier settings; interpreted by the notifiers package."""
38
+
39
+ type: str
40
+ options: dict[str, Any] = field(default_factory=dict)
41
+
42
+
43
+ @dataclass
44
+ class Config:
45
+ """The fully parsed certminder configuration."""
46
+
47
+ targets: list[Target]
48
+ notifiers: list[NotifierConfig]
49
+ certinspect_bin: str = "certinspect"
50
+ interval: int = 21600 # 6h
51
+ state_file: Path = Path("~/.certminder/state.json")
52
+ concurrency: int = 8
53
+ prometheus_file: Path | None = None
54
+
55
+
56
+ def _build_target(raw: dict[str, Any], defaults: dict[str, Any]) -> Target:
57
+ if "host" not in raw:
58
+ raise ConfigError(f"target is missing required 'host': {raw!r}")
59
+ merged = {**defaults, **raw}
60
+ allowed = {
61
+ "host",
62
+ "port",
63
+ "verify",
64
+ "days",
65
+ "critical_days",
66
+ "timeout",
67
+ "starttls",
68
+ "cafile",
69
+ "capath",
70
+ "label",
71
+ }
72
+ unknown = set(merged) - allowed
73
+ if unknown:
74
+ raise ConfigError(f"unknown target keys {sorted(unknown)} in {raw!r}")
75
+ return Target(**merged)
76
+
77
+
78
+ def load_config(path: str | Path) -> Config:
79
+ """Read, parse and validate the configuration at ``path``."""
80
+ path = Path(path).expanduser()
81
+ if not path.is_file():
82
+ raise ConfigError(f"config file not found: {path}")
83
+
84
+ try:
85
+ data = yaml.safe_load(path.read_text()) or {}
86
+ except yaml.YAMLError as exc: # pragma: no cover - passthrough
87
+ raise ConfigError(f"could not parse YAML: {exc}") from exc
88
+
89
+ if not isinstance(data, dict):
90
+ raise ConfigError("top-level configuration must be a mapping")
91
+
92
+ raw_targets = data.get("targets") or []
93
+ if not raw_targets:
94
+ raise ConfigError("at least one target is required")
95
+
96
+ defaults = data.get("defaults") or {}
97
+ targets = [_build_target(t, defaults) for t in raw_targets]
98
+
99
+ notifiers = []
100
+ for entry in data.get("notifiers") or [{"type": "console"}]:
101
+ if "type" not in entry:
102
+ raise ConfigError(f"notifier is missing 'type': {entry!r}")
103
+ options = {k: v for k, v in entry.items() if k != "type"}
104
+ notifiers.append(NotifierConfig(type=entry["type"], options=options))
105
+
106
+ return Config(
107
+ targets=targets,
108
+ notifiers=notifiers,
109
+ certinspect_bin=data.get("certinspect_bin", "certinspect"),
110
+ interval=parse_duration(data.get("interval", "6h")),
111
+ state_file=Path(
112
+ data.get("state_file", "~/.certminder/state.json")
113
+ ).expanduser(),
114
+ concurrency=int(data.get("concurrency", 8)),
115
+ prometheus_file=(
116
+ Path(data["prometheus_file"]).expanduser()
117
+ if data.get("prometheus_file")
118
+ else None
119
+ ),
120
+ )
certminder/engine.py ADDED
@@ -0,0 +1,129 @@
1
+ """Run certinspect against a target and normalise its result.
2
+
3
+ certminder never re-implements TLS or X.509 logic: it shells out to certinspect
4
+ (one target per invocation, ``--json``) and trusts its exit code as the
5
+ authoritative status. The exit-code contract is:
6
+
7
+ 0 VALID 3 EXPIRING 4 CRITICAL / EXPIRED / INVALID DATES
8
+ 5 HOSTNAME mismatch 6 chain untrusted or REVOKED 7 pin mismatch
9
+ 1 runtime error (e.g. unreachable) 2 usage error
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import subprocess
16
+ from typing import Any
17
+
18
+ from certminder.models import CheckResult, Target
19
+
20
+ # Exit codes that still produce a usable JSON document (the certificate was
21
+ # fetched and analysed; it simply has a problem).
22
+ _ANALYSED_CODES = {0, 3, 4, 5, 6, 7}
23
+
24
+
25
+ def build_command(bin_path: str, target: Target) -> list[str]:
26
+ """Assemble the certinspect command line for ``target``."""
27
+ cmd = [
28
+ bin_path,
29
+ target.host,
30
+ "--json",
31
+ "--port",
32
+ str(target.port),
33
+ "--timeout",
34
+ str(target.timeout),
35
+ "--days",
36
+ str(target.days),
37
+ "--critical-days",
38
+ str(target.critical_days),
39
+ ]
40
+ if target.verify:
41
+ cmd.append("--verify")
42
+ if target.starttls:
43
+ cmd += ["--starttls", target.starttls]
44
+ if target.cafile:
45
+ cmd += ["--cafile", target.cafile]
46
+ if target.capath:
47
+ cmd += ["--capath", target.capath]
48
+ return cmd
49
+
50
+
51
+ def _status_from(exit_code: int, info: dict[str, Any]) -> str:
52
+ """Refine certinspect's exit code into a certminder status string."""
53
+ if exit_code == 0:
54
+ return "VALID"
55
+ if exit_code == 3:
56
+ return "EXPIRING"
57
+ if exit_code == 4:
58
+ days = info.get("days_to_expire")
59
+ if isinstance(days, int) and days < 0:
60
+ return "EXPIRED"
61
+ return "CRITICAL"
62
+ if exit_code == 5:
63
+ return "HOSTNAME_MISMATCH"
64
+ if exit_code == 6:
65
+ if info.get("revocation_status") == "REVOKED":
66
+ return "REVOKED"
67
+ return "CHAIN_UNTRUSTED"
68
+ if exit_code == 7:
69
+ return "PIN_MISMATCH"
70
+ return "UNREACHABLE"
71
+
72
+
73
+ def check_target(target: Target, bin_path: str = "certinspect") -> CheckResult:
74
+ """Inspect a single target and return a normalised :class:`CheckResult`."""
75
+ cmd = build_command(bin_path, target)
76
+ try:
77
+ proc = subprocess.run(
78
+ cmd,
79
+ capture_output=True,
80
+ text=True,
81
+ timeout=target.timeout + 30,
82
+ )
83
+ except FileNotFoundError:
84
+ return CheckResult(
85
+ target=target,
86
+ reachable=False,
87
+ status="ERROR",
88
+ exit_code=127,
89
+ error=f"certinspect executable not found: {bin_path!r}",
90
+ )
91
+ except subprocess.TimeoutExpired:
92
+ return CheckResult(
93
+ target=target,
94
+ reachable=False,
95
+ status="UNREACHABLE",
96
+ exit_code=124,
97
+ error="certinspect timed out",
98
+ )
99
+
100
+ info: dict[str, Any] = {}
101
+ if proc.returncode in _ANALYSED_CODES:
102
+ try:
103
+ parsed = json.loads(proc.stdout or "[]")
104
+ if isinstance(parsed, list) and parsed:
105
+ info = parsed[0]
106
+ except json.JSONDecodeError:
107
+ info = {}
108
+
109
+ if proc.returncode not in _ANALYSED_CODES:
110
+ return CheckResult(
111
+ target=target,
112
+ reachable=False,
113
+ status="UNREACHABLE",
114
+ exit_code=proc.returncode,
115
+ error=(proc.stderr or proc.stdout or "").strip() or "inspection failed",
116
+ )
117
+
118
+ return CheckResult(
119
+ target=target,
120
+ reachable=True,
121
+ status=_status_from(proc.returncode, info),
122
+ exit_code=proc.returncode,
123
+ days_to_expire=info.get("days_to_expire"),
124
+ fingerprint=info.get("fingerprint_sha256"),
125
+ revocation=info.get("revocation_status"),
126
+ chain_trusted=info.get("chain_trusted"),
127
+ hostname_match=info.get("hostname_match"),
128
+ raw=info,
129
+ )
@@ -0,0 +1,130 @@
1
+ """Turn a check result plus prior state into a list of alert events.
2
+
3
+ The evaluator is pure: given the current :class:`CheckResult` and the previous
4
+ :class:`TargetState`, it returns the events to emit *this cycle* and the new
5
+ state to persist. Deduplication lives here: a problem already present in
6
+ ``active_alerts`` is not re-notified until it clears, at which point a single
7
+ ``RECOVERED`` event is emitted.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from certminder.models import CheckResult, Event, EventKind, Severity
13
+ from certminder.state import TargetState
14
+
15
+ # Map a status string to the event it raises and its severity.
16
+ _STATUS_EVENTS: dict[str, tuple[EventKind, Severity]] = {
17
+ "EXPIRING": (EventKind.EXPIRING, Severity.WARNING),
18
+ "CRITICAL": (EventKind.CRITICAL, Severity.CRITICAL),
19
+ "EXPIRED": (EventKind.EXPIRED, Severity.CRITICAL),
20
+ "REVOKED": (EventKind.REVOKED, Severity.CRITICAL),
21
+ "CHAIN_UNTRUSTED": (EventKind.CHAIN_UNTRUSTED, Severity.CRITICAL),
22
+ "HOSTNAME_MISMATCH": (EventKind.HOSTNAME_MISMATCH, Severity.CRITICAL),
23
+ }
24
+
25
+ _PROBLEM_STATUSES = set(_STATUS_EVENTS) | {"UNREACHABLE", "ERROR"}
26
+
27
+
28
+ def _message(result: CheckResult) -> str:
29
+ name = result.target.name
30
+ days = result.days_to_expire
31
+ if result.status in {"EXPIRING", "CRITICAL"}:
32
+ return f"{name}: certificate expires in {days} day(s)"
33
+ if result.status == "EXPIRED":
34
+ return f"{name}: certificate expired {abs(days) if days is not None else '?'} day(s) ago"
35
+ if result.status == "REVOKED":
36
+ return f"{name}: certificate is REVOKED"
37
+ if result.status == "CHAIN_UNTRUSTED":
38
+ return f"{name}: certificate chain is not trusted"
39
+ if result.status == "HOSTNAME_MISMATCH":
40
+ return f"{name}: certificate does not match the hostname"
41
+ return f"{name}: {result.status.lower()}"
42
+
43
+
44
+ def evaluate(
45
+ result: CheckResult, previous: TargetState
46
+ ) -> tuple[list[Event], TargetState]:
47
+ """Compare ``result`` against ``previous`` and return (events, new_state)."""
48
+ events: list[Event] = []
49
+ name = result.target.name
50
+ active = set(previous.active_alerts)
51
+ new_active: set[str] = set()
52
+
53
+ # Unreachable / executable errors.
54
+ if not result.reachable:
55
+ kind = EventKind.UNREACHABLE
56
+ key = f"{name}|{kind.value}"
57
+ new_active.add(key)
58
+ if key not in active:
59
+ events.append(
60
+ Event(
61
+ target_name=name,
62
+ kind=kind,
63
+ severity=Severity.CRITICAL,
64
+ message=f"{name}: unreachable ({result.error or 'no detail'})",
65
+ details={"error": result.error, "exit_code": result.exit_code},
66
+ )
67
+ )
68
+ # Keep the last known fingerprint; nothing new to compare.
69
+ return events, TargetState(
70
+ fingerprint=previous.fingerprint,
71
+ status=result.status,
72
+ active_alerts=sorted(new_active),
73
+ )
74
+
75
+ # Fingerprint change: report on every change (after the first sighting),
76
+ # regardless of validity — an unexpected rotation is itself the signal.
77
+ if (
78
+ previous.fingerprint
79
+ and result.fingerprint
80
+ and result.fingerprint != previous.fingerprint
81
+ ):
82
+ events.append(
83
+ Event(
84
+ target_name=name,
85
+ kind=EventKind.FINGERPRINT_CHANGED,
86
+ severity=Severity.WARNING,
87
+ message=f"{name}: certificate fingerprint changed",
88
+ details={
89
+ "old": previous.fingerprint,
90
+ "new": result.fingerprint,
91
+ },
92
+ )
93
+ )
94
+
95
+ # Validity-derived problems (deduplicated via active_alerts).
96
+ if result.status in _STATUS_EVENTS:
97
+ kind, severity = _STATUS_EVENTS[result.status]
98
+ key = f"{name}|{kind.value}"
99
+ new_active.add(key)
100
+ if key not in active:
101
+ events.append(
102
+ Event(
103
+ target_name=name,
104
+ kind=kind,
105
+ severity=severity,
106
+ message=_message(result),
107
+ details={"days_to_expire": result.days_to_expire},
108
+ )
109
+ )
110
+
111
+ # Recovery: previously had an active problem, now VALID.
112
+ cleared = active - new_active
113
+ if result.status == "VALID" and any(
114
+ not k.endswith(f"|{EventKind.FINGERPRINT_CHANGED.value}") for k in cleared
115
+ ):
116
+ events.append(
117
+ Event(
118
+ target_name=name,
119
+ kind=EventKind.RECOVERED,
120
+ severity=Severity.INFO,
121
+ message=f"{name}: recovered, certificate is valid again",
122
+ details={"days_to_expire": result.days_to_expire},
123
+ )
124
+ )
125
+
126
+ return events, TargetState(
127
+ fingerprint=result.fingerprint or previous.fingerprint,
128
+ status=result.status,
129
+ active_alerts=sorted(new_active),
130
+ )
certminder/metrics.py ADDED
@@ -0,0 +1,87 @@
1
+ """Render check results as Prometheus textfile-collector metrics.
2
+
3
+ The output is meant to be pointed at by the node_exporter ``textfile``
4
+ collector (``--collector.textfile.directory``). One ``.prom`` file is rewritten
5
+ atomically at the end of every cycle so a scrape never sees a half-written file.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import tempfile
12
+ import time
13
+ from pathlib import Path
14
+
15
+ from certminder.models import CheckResult
16
+
17
+
18
+ def _escape_label(value: str) -> str:
19
+ """Escape a Prometheus label value (backslash, quote, newline)."""
20
+ return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
21
+
22
+
23
+ def _labels(result: CheckResult) -> str:
24
+ target = result.target
25
+ parts = {
26
+ "target": target.name,
27
+ "host": target.host,
28
+ "port": str(target.port),
29
+ "status": result.status,
30
+ }
31
+ inner = ",".join(f'{k}="{_escape_label(v)}"' for k, v in parts.items())
32
+ return "{" + inner + "}"
33
+
34
+
35
+ def render(results: list[CheckResult], *, now: float | None = None) -> str:
36
+ """Build the Prometheus exposition text for ``results``."""
37
+ timestamp = time.time() if now is None else now
38
+ lines: list[str] = [
39
+ "# HELP certminder_certificate_expiry_days Days until the certificate expires.",
40
+ "# TYPE certminder_certificate_expiry_days gauge",
41
+ ]
42
+ for result in results:
43
+ if result.days_to_expire is not None:
44
+ lines.append(
45
+ f"certminder_certificate_expiry_days{_labels(result)} "
46
+ f"{result.days_to_expire}"
47
+ )
48
+
49
+ lines += [
50
+ "# HELP certminder_certificate_valid Whether the certificate is currently valid (1) or not (0).",
51
+ "# TYPE certminder_certificate_valid gauge",
52
+ ]
53
+ for result in results:
54
+ valid = 1 if result.status == "VALID" else 0
55
+ lines.append(f"certminder_certificate_valid{_labels(result)} {valid}")
56
+
57
+ lines += [
58
+ "# HELP certminder_target_up Whether the target was reachable this cycle (1) or not (0).",
59
+ "# TYPE certminder_target_up gauge",
60
+ ]
61
+ for result in results:
62
+ up = 1 if result.reachable else 0
63
+ lines.append(f"certminder_target_up{_labels(result)} {up}")
64
+
65
+ lines += [
66
+ "# HELP certminder_last_run_timestamp_seconds Unix time of the last completed cycle.",
67
+ "# TYPE certminder_last_run_timestamp_seconds gauge",
68
+ f"certminder_last_run_timestamp_seconds {timestamp:.0f}",
69
+ ]
70
+ return "\n".join(lines) + "\n"
71
+
72
+
73
+ def write_prometheus(
74
+ results: list[CheckResult], path: str | Path, *, now: float | None = None
75
+ ) -> None:
76
+ """Atomically write the Prometheus metrics for ``results`` to ``path``."""
77
+ path = Path(path).expanduser()
78
+ path.parent.mkdir(parents=True, exist_ok=True)
79
+ text = render(results, now=now)
80
+ fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
81
+ try:
82
+ with os.fdopen(fd, "w") as fh:
83
+ fh.write(text)
84
+ os.replace(tmp, path)
85
+ finally:
86
+ if os.path.exists(tmp):
87
+ os.unlink(tmp)
certminder/models.py ADDED
@@ -0,0 +1,91 @@
1
+ """Data structures shared across certminder.
2
+
3
+ These dataclasses are deliberately small and serializable so they can be
4
+ passed to notifiers and written to the state file without ceremony.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from enum import Enum
11
+ from typing import Any
12
+
13
+
14
+ class Severity(str, Enum):
15
+ """Ordered alert severity, low to high."""
16
+
17
+ INFO = "info"
18
+ WARNING = "warning"
19
+ CRITICAL = "critical"
20
+
21
+
22
+ class EventKind(str, Enum):
23
+ """The kinds of change certminder reports.
24
+
25
+ The first group derives from the certificate's own state; ``RECOVERED`` is
26
+ emitted once when a target returns to ``VALID`` after a prior problem.
27
+ """
28
+
29
+ EXPIRING = "expiring"
30
+ CRITICAL = "critical"
31
+ EXPIRED = "expired"
32
+ REVOKED = "revoked"
33
+ CHAIN_UNTRUSTED = "chain_untrusted"
34
+ HOSTNAME_MISMATCH = "hostname_mismatch"
35
+ FINGERPRINT_CHANGED = "fingerprint_changed"
36
+ UNREACHABLE = "unreachable"
37
+ RECOVERED = "recovered"
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Target:
42
+ """A single certificate endpoint to watch."""
43
+
44
+ host: str
45
+ port: int = 443
46
+ verify: bool = True
47
+ days: int = 30
48
+ critical_days: int = 15
49
+ timeout: float = 5.0
50
+ starttls: str | None = None
51
+ cafile: str | None = None
52
+ capath: str | None = None
53
+ label: str | None = None
54
+
55
+ @property
56
+ def name(self) -> str:
57
+ """A stable, human-readable identifier used as the state key."""
58
+ base = f"{self.host}:{self.port}"
59
+ return f"{base} ({self.label})" if self.label else base
60
+
61
+
62
+ @dataclass
63
+ class CheckResult:
64
+ """The outcome of inspecting one target in a single cycle."""
65
+
66
+ target: Target
67
+ reachable: bool
68
+ status: str
69
+ exit_code: int
70
+ days_to_expire: int | None = None
71
+ fingerprint: str | None = None
72
+ revocation: str | None = None
73
+ chain_trusted: bool | None = None
74
+ hostname_match: bool | None = None
75
+ error: str | None = None
76
+ raw: dict[str, Any] = field(default_factory=dict)
77
+
78
+
79
+ @dataclass
80
+ class Event:
81
+ """Something worth telling a human about."""
82
+
83
+ target_name: str
84
+ kind: EventKind
85
+ severity: Severity
86
+ message: str
87
+ details: dict[str, Any] = field(default_factory=dict)
88
+
89
+ def key(self) -> str:
90
+ """Identity used to deduplicate repeated alerts across cycles."""
91
+ return f"{self.target_name}|{self.kind.value}"
@@ -0,0 +1,35 @@
1
+ """Notifier registry and base class.
2
+
3
+ A notifier receives the events produced in a cycle and delivers them somewhere
4
+ (stdout, Slack, a generic webhook). New sinks register themselves in
5
+ :data:`REGISTRY` so the configuration's ``type`` field can resolve them.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from certminder.notifiers.base import Notifier
11
+ from certminder.notifiers.console import ConsoleNotifier
12
+ from certminder.notifiers.email import EmailNotifier
13
+ from certminder.notifiers.slack import SlackNotifier
14
+ from certminder.notifiers.webhook import WebhookNotifier
15
+
16
+ REGISTRY: dict[str, type[Notifier]] = {
17
+ "console": ConsoleNotifier,
18
+ "email": EmailNotifier,
19
+ "slack": SlackNotifier,
20
+ "webhook": WebhookNotifier,
21
+ }
22
+
23
+
24
+ def build_notifier(kind: str, options: dict) -> Notifier:
25
+ """Instantiate a notifier of ``kind`` with its options."""
26
+ try:
27
+ cls = REGISTRY[kind]
28
+ except KeyError as exc:
29
+ raise ValueError(
30
+ f"unknown notifier type {kind!r}; choose from {sorted(REGISTRY)}"
31
+ ) from exc
32
+ return cls(**options)
33
+
34
+
35
+ __all__ = ["Notifier", "REGISTRY", "build_notifier"]