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 +3 -0
- certminder/cli.py +100 -0
- certminder/config.py +120 -0
- certminder/engine.py +129 -0
- certminder/evaluator.py +130 -0
- certminder/metrics.py +87 -0
- certminder/models.py +91 -0
- certminder/notifiers/__init__.py +35 -0
- certminder/notifiers/base.py +17 -0
- certminder/notifiers/console.py +23 -0
- certminder/notifiers/email.py +102 -0
- certminder/notifiers/slack.py +46 -0
- certminder/notifiers/webhook.py +56 -0
- certminder/scheduler.py +97 -0
- certminder/state.py +80 -0
- certminder-0.2.0.dist-info/METADATA +178 -0
- certminder-0.2.0.dist-info/RECORD +21 -0
- certminder-0.2.0.dist-info/WHEEL +5 -0
- certminder-0.2.0.dist-info/entry_points.txt +2 -0
- certminder-0.2.0.dist-info/licenses/LICENSE +21 -0
- certminder-0.2.0.dist-info/top_level.txt +1 -0
certminder/__init__.py
ADDED
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
|
+
)
|
certminder/evaluator.py
ADDED
|
@@ -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"]
|