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.
- certminder-0.2.0/LICENSE +21 -0
- certminder-0.2.0/PKG-INFO +178 -0
- certminder-0.2.0/README.md +152 -0
- certminder-0.2.0/pyproject.toml +47 -0
- certminder-0.2.0/setup.cfg +4 -0
- certminder-0.2.0/src/certminder/__init__.py +3 -0
- certminder-0.2.0/src/certminder/cli.py +100 -0
- certminder-0.2.0/src/certminder/config.py +120 -0
- certminder-0.2.0/src/certminder/engine.py +129 -0
- certminder-0.2.0/src/certminder/evaluator.py +130 -0
- certminder-0.2.0/src/certminder/metrics.py +87 -0
- certminder-0.2.0/src/certminder/models.py +91 -0
- certminder-0.2.0/src/certminder/notifiers/__init__.py +35 -0
- certminder-0.2.0/src/certminder/notifiers/base.py +17 -0
- certminder-0.2.0/src/certminder/notifiers/console.py +23 -0
- certminder-0.2.0/src/certminder/notifiers/email.py +102 -0
- certminder-0.2.0/src/certminder/notifiers/slack.py +46 -0
- certminder-0.2.0/src/certminder/notifiers/webhook.py +56 -0
- certminder-0.2.0/src/certminder/scheduler.py +97 -0
- certminder-0.2.0/src/certminder/state.py +80 -0
- certminder-0.2.0/src/certminder.egg-info/PKG-INFO +178 -0
- certminder-0.2.0/src/certminder.egg-info/SOURCES.txt +31 -0
- certminder-0.2.0/src/certminder.egg-info/dependency_links.txt +1 -0
- certminder-0.2.0/src/certminder.egg-info/entry_points.txt +2 -0
- certminder-0.2.0/src/certminder.egg-info/requires.txt +6 -0
- certminder-0.2.0/src/certminder.egg-info/top_level.txt +1 -0
- certminder-0.2.0/tests/test_config.py +89 -0
- certminder-0.2.0/tests/test_engine.py +83 -0
- certminder-0.2.0/tests/test_evaluator.py +76 -0
- certminder-0.2.0/tests/test_metrics.py +67 -0
- certminder-0.2.0/tests/test_notifiers.py +154 -0
- certminder-0.2.0/tests/test_scheduler.py +63 -0
- certminder-0.2.0/tests/test_state.py +40 -0
certminder-0.2.0/LICENSE
ADDED
|
@@ -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
|
+
[](https://github.com/mangrisano/certminder/actions/workflows/ci.yml)
|
|
32
|
+
[](https://pypi.org/project/certminder/)
|
|
33
|
+
[](https://pypi.org/project/certminder/)
|
|
34
|
+
[](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
|
+
[](https://github.com/mangrisano/certminder/actions/workflows/ci.yml)
|
|
6
|
+
[](https://pypi.org/project/certminder/)
|
|
7
|
+
[](https://pypi.org/project/certminder/)
|
|
8
|
+
[](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,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
|
+
)
|