certminder 0.2.0__tar.gz

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.
Files changed (33) hide show
  1. certminder-0.2.0/LICENSE +21 -0
  2. certminder-0.2.0/PKG-INFO +178 -0
  3. certminder-0.2.0/README.md +152 -0
  4. certminder-0.2.0/pyproject.toml +47 -0
  5. certminder-0.2.0/setup.cfg +4 -0
  6. certminder-0.2.0/src/certminder/__init__.py +3 -0
  7. certminder-0.2.0/src/certminder/cli.py +100 -0
  8. certminder-0.2.0/src/certminder/config.py +120 -0
  9. certminder-0.2.0/src/certminder/engine.py +129 -0
  10. certminder-0.2.0/src/certminder/evaluator.py +130 -0
  11. certminder-0.2.0/src/certminder/metrics.py +87 -0
  12. certminder-0.2.0/src/certminder/models.py +91 -0
  13. certminder-0.2.0/src/certminder/notifiers/__init__.py +35 -0
  14. certminder-0.2.0/src/certminder/notifiers/base.py +17 -0
  15. certminder-0.2.0/src/certminder/notifiers/console.py +23 -0
  16. certminder-0.2.0/src/certminder/notifiers/email.py +102 -0
  17. certminder-0.2.0/src/certminder/notifiers/slack.py +46 -0
  18. certminder-0.2.0/src/certminder/notifiers/webhook.py +56 -0
  19. certminder-0.2.0/src/certminder/scheduler.py +97 -0
  20. certminder-0.2.0/src/certminder/state.py +80 -0
  21. certminder-0.2.0/src/certminder.egg-info/PKG-INFO +178 -0
  22. certminder-0.2.0/src/certminder.egg-info/SOURCES.txt +31 -0
  23. certminder-0.2.0/src/certminder.egg-info/dependency_links.txt +1 -0
  24. certminder-0.2.0/src/certminder.egg-info/entry_points.txt +2 -0
  25. certminder-0.2.0/src/certminder.egg-info/requires.txt +6 -0
  26. certminder-0.2.0/src/certminder.egg-info/top_level.txt +1 -0
  27. certminder-0.2.0/tests/test_config.py +89 -0
  28. certminder-0.2.0/tests/test_engine.py +83 -0
  29. certminder-0.2.0/tests/test_evaluator.py +76 -0
  30. certminder-0.2.0/tests/test_metrics.py +67 -0
  31. certminder-0.2.0/tests/test_notifiers.py +154 -0
  32. certminder-0.2.0/tests/test_scheduler.py +63 -0
  33. certminder-0.2.0/tests/test_state.py +40 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michele Angrisano
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,178 @@
1
+ Metadata-Version: 2.4
2
+ Name: certminder
3
+ Version: 0.2.0
4
+ Summary: Continuous TLS certificate monitoring and alerting, powered by certinspect.
5
+ Author-email: Michele Angrisano <michele.angrisano@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/mangrisano/certminder
8
+ Project-URL: Repository, https://github.com/mangrisano/certminder
9
+ Project-URL: Issues, https://github.com/mangrisano/certminder/issues
10
+ Keywords: tls,ssl,x509,certificate,monitoring,alerting,pki
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: System Administrators
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: System :: Monitoring
16
+ Classifier: Topic :: Security
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: certinspect>=1.0.0
21
+ Requires-Dist: PyYAML>=6.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=8.0; extra == "dev"
24
+ Requires-Dist: ruff>=0.5; extra == "dev"
25
+ Dynamic: license-file
26
+
27
+ <div align="center">
28
+
29
+ <img src="https://raw.githubusercontent.com/mangrisano/certminder/main/docs/logo.svg" alt="certminder" width="440">
30
+
31
+ [![CI](https://github.com/mangrisano/certminder/actions/workflows/ci.yml/badge.svg)](https://github.com/mangrisano/certminder/actions/workflows/ci.yml)
32
+ [![PyPI](https://img.shields.io/pypi/v/certminder.svg)](https://pypi.org/project/certminder/)
33
+ [![Python](https://img.shields.io/pypi/pyversions/certminder.svg)](https://pypi.org/project/certminder/)
34
+ [![License: MIT](https://img.shields.io/pypi/l/certminder.svg)](LICENSE)
35
+
36
+ **Scheduled checks · Expiry & revocation alerts · Fingerprint change detection · Deduplicated notifications · Console / email / Slack / webhook · Prometheus metrics**
37
+
38
+ [PyPI](https://pypi.org/project/certminder/) · [Quick start](#quick-start) · [Configure](#configure) · [Alerts](#what-it-alerts-on) · [Prometheus](#prometheus-metrics) · [Deployment](#deployment) · [Issues](https://github.com/mangrisano/certminder/issues)
39
+
40
+ </div>
41
+
42
+ **Continuous TLS certificate monitoring and alerting** — the watch loop on top
43
+ of [certinspect](https://github.com/mangrisano/certinspect).
44
+
45
+ `certinspect` tells you what a certificate looks like _right now_.
46
+ `certminder` runs it on a schedule, remembers what it saw last time, and
47
+ **alerts you when a certificate is about to expire, gets revoked, changes
48
+ fingerprint, or becomes unreachable**.
49
+
50
+ ## Why a separate tool
51
+
52
+ certminder never re-implements TLS or X.509 logic — that all lives in
53
+ certinspect. certminder adds only what a monitor needs:
54
+
55
+ - a **schedule** (run once for cron, or loop as a daemon),
56
+ - **state memory** to detect _changes_ between runs,
57
+ - **deduplicated alerts** (notify once per condition, recover once),
58
+ - pluggable **notifiers** (console, email, Slack, generic webhook),
59
+ - optional **Prometheus** metrics for the node_exporter textfile collector.
60
+
61
+ ## Install
62
+
63
+ ```bash
64
+ pip install certminder # pulls in certinspect automatically
65
+ # or from source:
66
+ pip install -e '.[dev]'
67
+ ```
68
+
69
+ ## Quick start
70
+
71
+ ```bash
72
+ # inspect a single host ad hoc
73
+ certminder check example.com
74
+
75
+ # copy and edit the sample config, then:
76
+ certminder once -c certminder.yml # one cycle — ideal for cron
77
+ certminder run -c certminder.yml # run continuously as a daemon
78
+ ```
79
+
80
+ ## Configure
81
+
82
+ Everything is driven by a YAML file (see
83
+ [`certminder.example.yml`](certminder.example.yml)):
84
+
85
+ ```yaml
86
+ interval: 6h
87
+ state_file: ~/.certminder/state.json
88
+ defaults:
89
+ verify: true
90
+ days: 30
91
+ critical_days: 15
92
+ notifiers:
93
+ - type: console
94
+ - type: slack
95
+ webhook_url: "https://hooks.slack.com/services/XXX/YYY/ZZZ"
96
+ - type: email
97
+ host: smtp.example.com
98
+ port: 587
99
+ username: alerts@example.com
100
+ password: CHANGE_ME
101
+ from_addr: alerts@example.com
102
+ to: [ops@example.com]
103
+ targets:
104
+ - host: example.com
105
+ - host: api.example.com
106
+ port: 8443
107
+ - host: mail.example.com
108
+ starttls: smtp
109
+ ```
110
+
111
+ ## What it alerts on
112
+
113
+ | Event | Severity | Trigger |
114
+ | ---------------------- | -------- | ------------------------------------------ |
115
+ | `EXPIRING` | warning | within `--days` of expiry |
116
+ | `CRITICAL` / `EXPIRED` | critical | within `critical_days`, or already expired |
117
+ | `REVOKED` | critical | OCSP/CRL says revoked (needs `verify`) |
118
+ | `CHAIN_UNTRUSTED` | critical | chain fails to validate |
119
+ | `HOSTNAME_MISMATCH` | critical | cert does not match the hostname |
120
+ | `FINGERPRINT_CHANGED` | warning | fingerprint differs from last cycle |
121
+ | `UNREACHABLE` | critical | host/handshake failed |
122
+ | `RECOVERED` | info | a prior problem cleared |
123
+
124
+ Each condition alerts **once**; certminder remembers it and stays quiet until it
125
+ changes, then sends a single recovery notice.
126
+
127
+ ## Exit codes (`once`)
128
+
129
+ - `0` — no events this cycle
130
+ - `1` — at least one event was emitted
131
+ - `2` — configuration error
132
+
133
+ Add `--json` to `once` to print a machine-readable summary of the cycle (one
134
+ entry per target plus the events) to stdout, handy for piping:
135
+
136
+ ```bash
137
+ certminder once -c certminder.yml --json | jq '.targets[] | {target, status, days_to_expire}'
138
+ ```
139
+
140
+ ## Prometheus metrics
141
+
142
+ Set `prometheus_file` in the config to a path inside the node_exporter
143
+ [textfile collector](https://github.com/prometheus/node_exporter#textfile-collector)
144
+ directory. certminder rewrites it atomically at the end of every cycle:
145
+
146
+ ```
147
+ certminder_certificate_expiry_days{target="example.com:443",host="example.com",port="443",status="VALID"} 42
148
+ certminder_certificate_valid{...} 1
149
+ certminder_target_up{...} 1
150
+ certminder_last_run_timestamp_seconds 1700000000
151
+ ```
152
+
153
+ ## Deployment
154
+
155
+ Ready-to-use units live in [`deploy/`](deploy/) plus a [`Dockerfile`](Dockerfile):
156
+
157
+ - **systemd timer** — [`certminder.service`](deploy/systemd/certminder.service) +
158
+ [`certminder.timer`](deploy/systemd/certminder.timer) run one cycle on a
159
+ schedule (cron-style, recommended).
160
+ - **systemd daemon** — [`certminder-daemon.service`](deploy/systemd/certminder-daemon.service)
161
+ runs the `run` loop under supervision.
162
+ - **cron** — [`certminder.cron`](deploy/cron/certminder.cron) for hosts without
163
+ systemd timers.
164
+ - **Docker** — multi-stage build; mount your `certminder.yml` at
165
+ `/etc/certminder/certminder.yml` and a volume at `/var/lib/certminder`.
166
+
167
+ ## Development
168
+
169
+ ```bash
170
+ ruff check . && ruff format --check .
171
+ pytest -q
172
+ ```
173
+
174
+ Tests mock the certinspect subprocess, so the suite never touches the network.
175
+
176
+ ## License
177
+
178
+ MIT © Michele Angrisano
@@ -0,0 +1,152 @@
1
+ <div align="center">
2
+
3
+ <img src="https://raw.githubusercontent.com/mangrisano/certminder/main/docs/logo.svg" alt="certminder" width="440">
4
+
5
+ [![CI](https://github.com/mangrisano/certminder/actions/workflows/ci.yml/badge.svg)](https://github.com/mangrisano/certminder/actions/workflows/ci.yml)
6
+ [![PyPI](https://img.shields.io/pypi/v/certminder.svg)](https://pypi.org/project/certminder/)
7
+ [![Python](https://img.shields.io/pypi/pyversions/certminder.svg)](https://pypi.org/project/certminder/)
8
+ [![License: MIT](https://img.shields.io/pypi/l/certminder.svg)](LICENSE)
9
+
10
+ **Scheduled checks · Expiry & revocation alerts · Fingerprint change detection · Deduplicated notifications · Console / email / Slack / webhook · Prometheus metrics**
11
+
12
+ [PyPI](https://pypi.org/project/certminder/) · [Quick start](#quick-start) · [Configure](#configure) · [Alerts](#what-it-alerts-on) · [Prometheus](#prometheus-metrics) · [Deployment](#deployment) · [Issues](https://github.com/mangrisano/certminder/issues)
13
+
14
+ </div>
15
+
16
+ **Continuous TLS certificate monitoring and alerting** — the watch loop on top
17
+ of [certinspect](https://github.com/mangrisano/certinspect).
18
+
19
+ `certinspect` tells you what a certificate looks like _right now_.
20
+ `certminder` runs it on a schedule, remembers what it saw last time, and
21
+ **alerts you when a certificate is about to expire, gets revoked, changes
22
+ fingerprint, or becomes unreachable**.
23
+
24
+ ## Why a separate tool
25
+
26
+ certminder never re-implements TLS or X.509 logic — that all lives in
27
+ certinspect. certminder adds only what a monitor needs:
28
+
29
+ - a **schedule** (run once for cron, or loop as a daemon),
30
+ - **state memory** to detect _changes_ between runs,
31
+ - **deduplicated alerts** (notify once per condition, recover once),
32
+ - pluggable **notifiers** (console, email, Slack, generic webhook),
33
+ - optional **Prometheus** metrics for the node_exporter textfile collector.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install certminder # pulls in certinspect automatically
39
+ # or from source:
40
+ pip install -e '.[dev]'
41
+ ```
42
+
43
+ ## Quick start
44
+
45
+ ```bash
46
+ # inspect a single host ad hoc
47
+ certminder check example.com
48
+
49
+ # copy and edit the sample config, then:
50
+ certminder once -c certminder.yml # one cycle — ideal for cron
51
+ certminder run -c certminder.yml # run continuously as a daemon
52
+ ```
53
+
54
+ ## Configure
55
+
56
+ Everything is driven by a YAML file (see
57
+ [`certminder.example.yml`](certminder.example.yml)):
58
+
59
+ ```yaml
60
+ interval: 6h
61
+ state_file: ~/.certminder/state.json
62
+ defaults:
63
+ verify: true
64
+ days: 30
65
+ critical_days: 15
66
+ notifiers:
67
+ - type: console
68
+ - type: slack
69
+ webhook_url: "https://hooks.slack.com/services/XXX/YYY/ZZZ"
70
+ - type: email
71
+ host: smtp.example.com
72
+ port: 587
73
+ username: alerts@example.com
74
+ password: CHANGE_ME
75
+ from_addr: alerts@example.com
76
+ to: [ops@example.com]
77
+ targets:
78
+ - host: example.com
79
+ - host: api.example.com
80
+ port: 8443
81
+ - host: mail.example.com
82
+ starttls: smtp
83
+ ```
84
+
85
+ ## What it alerts on
86
+
87
+ | Event | Severity | Trigger |
88
+ | ---------------------- | -------- | ------------------------------------------ |
89
+ | `EXPIRING` | warning | within `--days` of expiry |
90
+ | `CRITICAL` / `EXPIRED` | critical | within `critical_days`, or already expired |
91
+ | `REVOKED` | critical | OCSP/CRL says revoked (needs `verify`) |
92
+ | `CHAIN_UNTRUSTED` | critical | chain fails to validate |
93
+ | `HOSTNAME_MISMATCH` | critical | cert does not match the hostname |
94
+ | `FINGERPRINT_CHANGED` | warning | fingerprint differs from last cycle |
95
+ | `UNREACHABLE` | critical | host/handshake failed |
96
+ | `RECOVERED` | info | a prior problem cleared |
97
+
98
+ Each condition alerts **once**; certminder remembers it and stays quiet until it
99
+ changes, then sends a single recovery notice.
100
+
101
+ ## Exit codes (`once`)
102
+
103
+ - `0` — no events this cycle
104
+ - `1` — at least one event was emitted
105
+ - `2` — configuration error
106
+
107
+ Add `--json` to `once` to print a machine-readable summary of the cycle (one
108
+ entry per target plus the events) to stdout, handy for piping:
109
+
110
+ ```bash
111
+ certminder once -c certminder.yml --json | jq '.targets[] | {target, status, days_to_expire}'
112
+ ```
113
+
114
+ ## Prometheus metrics
115
+
116
+ Set `prometheus_file` in the config to a path inside the node_exporter
117
+ [textfile collector](https://github.com/prometheus/node_exporter#textfile-collector)
118
+ directory. certminder rewrites it atomically at the end of every cycle:
119
+
120
+ ```
121
+ certminder_certificate_expiry_days{target="example.com:443",host="example.com",port="443",status="VALID"} 42
122
+ certminder_certificate_valid{...} 1
123
+ certminder_target_up{...} 1
124
+ certminder_last_run_timestamp_seconds 1700000000
125
+ ```
126
+
127
+ ## Deployment
128
+
129
+ Ready-to-use units live in [`deploy/`](deploy/) plus a [`Dockerfile`](Dockerfile):
130
+
131
+ - **systemd timer** — [`certminder.service`](deploy/systemd/certminder.service) +
132
+ [`certminder.timer`](deploy/systemd/certminder.timer) run one cycle on a
133
+ schedule (cron-style, recommended).
134
+ - **systemd daemon** — [`certminder-daemon.service`](deploy/systemd/certminder-daemon.service)
135
+ runs the `run` loop under supervision.
136
+ - **cron** — [`certminder.cron`](deploy/cron/certminder.cron) for hosts without
137
+ systemd timers.
138
+ - **Docker** — multi-stage build; mount your `certminder.yml` at
139
+ `/etc/certminder/certminder.yml` and a volume at `/var/lib/certminder`.
140
+
141
+ ## Development
142
+
143
+ ```bash
144
+ ruff check . && ruff format --check .
145
+ pytest -q
146
+ ```
147
+
148
+ Tests mock the certinspect subprocess, so the suite never touches the network.
149
+
150
+ ## License
151
+
152
+ MIT © Michele Angrisano
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "certminder"
7
+ version = "0.2.0"
8
+ description = "Continuous TLS certificate monitoring and alerting, powered by certinspect."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Michele Angrisano", email = "michele.angrisano@gmail.com" }]
14
+ keywords = ["tls", "ssl", "x509", "certificate", "monitoring", "alerting", "pki"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Environment :: Console",
18
+ "Intended Audience :: System Administrators",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: System :: Monitoring",
21
+ "Topic :: Security",
22
+ ]
23
+ dependencies = [
24
+ "certinspect>=1.0.0",
25
+ "PyYAML>=6.0",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ dev = ["pytest>=8.0", "ruff>=0.5"]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/mangrisano/certminder"
33
+ Repository = "https://github.com/mangrisano/certminder"
34
+ Issues = "https://github.com/mangrisano/certminder/issues"
35
+
36
+ [project.scripts]
37
+ certminder = "certminder.cli:main"
38
+
39
+ [tool.setuptools.packages.find]
40
+ where = ["src"]
41
+
42
+ [tool.ruff]
43
+ line-length = 88
44
+ src = ["src", "tests"]
45
+
46
+ [tool.pytest.ini_options]
47
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """certminder: continuous TLS certificate monitoring built on top of certinspect."""
2
+
3
+ __version__ = "0.2.0"
@@ -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())
@@ -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
+ )