Skip to content

Commit bf86a99

Browse files
committed
Improve logging of certificate status
1 parent debb7e6 commit bf86a99

3 files changed

Lines changed: 315 additions & 3 deletions

File tree

nginx_proxy/post_processors/ssl_certificate_processor.py

Lines changed: 139 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
from datetime import datetime, timezone
2-
from typing import List, Tuple
1+
from datetime import datetime, timedelta, timezone
2+
from typing import List, Optional, Tuple
33

44
from certapi.client import RenewalManager
55
from certapi.crypto import Key, Certificate
@@ -37,12 +37,21 @@ def __init__(
3737
renew_threshold_days=max(1, int(self.update_threshold_secs // (24 * 3600))),
3838
batch_domains=self.certapi_batch_domains,
3939
)
40+
self._status_rows: List[Tuple[str, str, Optional[datetime]]] = []
41+
self._last_logged_status: Optional[Tuple] = None
4042

4143
if start_ssl_thread:
4244
self.start()
4345

4446
def start(self):
4547
self.renewal_manager.start()
48+
backend = f"certapi {self.certapi_url}" if self.use_certapi_server else "local ACME"
49+
print(
50+
f"[SSL Refresh Thread] Started with backend={backend}, "
51+
f"renew threshold {self._format_duration(self.update_threshold_secs)}, "
52+
f"watching {len(self._status_rows)} domains"
53+
)
54+
self._log_next_check()
4655

4756
def ssl_renewal_callback(self):
4857
print("[SSL] Renewal callback triggered")
@@ -123,6 +132,134 @@ def process_ssl_certificates(self, hosts: List[Host], update_watch_domains: bool
123132
for host in secured_hosts:
124133
host.ssl_file = self._select_ssl_file(host)
125134

135+
if update_watch_domains:
136+
self.log_certificate_status(secured_hosts)
137+
138+
# ------------------------------------------------------------------
139+
# Status logging
140+
# ------------------------------------------------------------------
141+
142+
def _certificate_expiry(self, cert_name: str) -> Optional[datetime]:
143+
try:
144+
result = self.key_store.find_key_and_cert_by_cert_id(cert_name)
145+
except Exception:
146+
return None
147+
if not isinstance(result, (tuple, list)) or len(result) < 2:
148+
return None
149+
certs = result[1]
150+
if not isinstance(certs, (list, tuple)) or not certs:
151+
return None
152+
expiry = getattr(certs[0], "not_valid_after_utc", None)
153+
return expiry if isinstance(expiry, datetime) else None
154+
155+
def _collect_status_rows(self, hosts: List[Host]) -> List[Tuple[str, str, Optional[datetime]]]:
156+
rows = {}
157+
for host in hosts:
158+
if host.hostname in rows:
159+
continue
160+
ssl_file = host.ssl_file or ""
161+
rows[host.hostname] = (host.hostname, ssl_file, self._certificate_expiry(ssl_file) if ssl_file else None)
162+
return [rows[name] for name in sorted(rows)]
163+
164+
@staticmethod
165+
def _format_duration(seconds: float) -> str:
166+
"""Human friendly duration: '30 days', '5 hours' or '29 minutes'."""
167+
seconds = max(0, int(seconds))
168+
days, rem = divmod(seconds, 24 * 3600)
169+
if days:
170+
return f"{days} day{'s' if days != 1 else ''}"
171+
hours, rem = divmod(rem, 3600)
172+
if hours:
173+
return f"{hours} hour{'s' if hours != 1 else ''}"
174+
minutes = max(1, rem // 60)
175+
return f"{minutes} minute{'s' if minutes != 1 else ''}"
176+
177+
@staticmethod
178+
def _format_remaining(delta: timedelta) -> str:
179+
"""'75 days, 17:00:58' style: no microseconds, zero padded time so columns line up."""
180+
total = int(delta.total_seconds())
181+
days, rem = divmod(total, 24 * 3600)
182+
hours, rem = divmod(rem, 3600)
183+
minutes, seconds = divmod(rem, 60)
184+
prefix = f"{days} day{'s' if days != 1 else ''}, " if days else ""
185+
return f"{prefix}{hours:02}:{minutes:02}:{seconds:02}"
186+
187+
def _row_text(self, hostname: str, ssl_file: str, expiry: Optional[datetime], now: datetime) -> str:
188+
if not ssl_file or expiry is None:
189+
return "no certificate"
190+
if expiry <= now:
191+
text = f"EXPIRED {self._format_remaining(now - expiry)} ago"
192+
else:
193+
text = self._format_remaining(expiry - now)
194+
if ssl_file != hostname:
195+
text += f" ({ssl_file})"
196+
return text
197+
198+
def _next_check(self, now: datetime) -> Optional[Tuple[float, str, datetime]]:
199+
"""Return (seconds_until_check, domain, expiry) for the earliest real certificate, or None."""
200+
candidates = [
201+
(expiry, domain)
202+
for domain, ssl_file, expiry in self._status_rows
203+
if expiry is not None and not ssl_file.endswith(".selfsigned") and expiry > now
204+
]
205+
if not candidates:
206+
return None
207+
expiry, domain = min(candidates)
208+
threshold = getattr(self.renewal_manager, "update_threshold_secs", None)
209+
if not isinstance(threshold, (int, float)):
210+
threshold = self.update_threshold_secs
211+
slack = getattr(self.renewal_manager, "sleep_slack_seconds", None)
212+
if not isinstance(slack, (int, float)):
213+
slack = 300
214+
max_sleep = getattr(self.renewal_manager, "max_sleep_seconds", None)
215+
if not isinstance(max_sleep, (int, float)):
216+
max_sleep = 32 * 24 * 3600
217+
wait = (expiry - now).total_seconds() - threshold
218+
wait = min(wait + slack, max_sleep) if wait > 0 else 0
219+
return wait, domain, expiry
220+
221+
def _log_next_check(self, now: Optional[datetime] = None):
222+
now = now or datetime.now(timezone.utc)
223+
next_check = self._next_check(now)
224+
if next_check is None:
225+
print("[SSL Refresh Thread] Looks like there are no ssl certificates, sleeping until there's one")
226+
return
227+
wait, domain, expiry = next_check
228+
if wait <= 0:
229+
print(
230+
f"[SSL Refresh Thread] Looks like we need to refresh certificates that are about to expire "
231+
f"({domain} expires in {self._format_remaining(expiry - now)})"
232+
)
233+
else:
234+
print(
235+
f"[SSL Refresh Thread] All the certificates are up to date sleeping for {self._format_duration(wait)}."
236+
)
237+
238+
def log_certificate_status(self, hosts: List[Host], force: bool = False):
239+
"""
240+
Print the watched domains with the time left on the certificate each one serves, followed by
241+
when the renewal thread will check again. Printed only when something changed unless forced.
242+
"""
243+
try:
244+
now = datetime.now(timezone.utc)
245+
self._status_rows = self._collect_status_rows(hosts)
246+
snapshot = tuple((d, f, e.isoformat() if e else None) for d, f, e in self._status_rows)
247+
if not force and snapshot == self._last_logged_status:
248+
return
249+
self._last_logged_status = snapshot
250+
251+
real_rows = [row for row in self._status_rows if not row[1].endswith(".selfsigned")]
252+
self_signed = [row[0] for row in self._status_rows if row[1].endswith(".selfsigned")]
253+
print("[SSL Refresh Thread] SSL certificate status:")
254+
max_size = max([len(d) for d, _, _ in real_rows] + [0])
255+
for domain, ssl_file, expiry in real_rows:
256+
print(f" {domain:<{max_size + 2}} - {self._row_text(domain, ssl_file, expiry, now)}")
257+
self._log_next_check(now)
258+
if self_signed:
259+
print(f"[SSL Refresh Thread] Selfsigned: {', '.join(self_signed)}")
260+
except Exception as e: # logging must never break a reload
261+
print(f"[SSL Refresh Thread] Could not render certificate status: {e.__class__.__name__}: {e}")
262+
126263
def wildcard_domain_name(self, domain, wild_char="*"):
127264
slices = domain.split(".")
128265
if len(slices) > 2:

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@ docker==7.1.0
22
Jinja2==3.1.6
33
pydevd==3.1.0
44
bcrypt==4.3.0 # 5.0.0 requires rust so ignoring
5-
certapi>=1.1.13
5+
certapi>=1.1.15
66
requests==2.33.0
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
from datetime import datetime, timedelta, timezone
2+
from types import SimpleNamespace
3+
from unittest.mock import Mock, patch
4+
5+
from nginx_proxy.Host import Host
6+
from nginx_proxy.post_processors.ssl_certificate_processor import SslCertificateProcessor
7+
8+
9+
def _cert(expiry: datetime):
10+
return SimpleNamespace(not_valid_after_utc=expiry)
11+
12+
13+
def _build_processor(cert_expiries: dict, threshold_days=30):
14+
"""cert_expiries maps certificate file name -> expiry datetime (None means the file is missing)."""
15+
key_store = Mock()
16+
17+
def by_cert_id(name):
18+
expiry = cert_expiries.get(name)
19+
return None if expiry is None else (Mock(), [_cert(expiry)])
20+
21+
def by_domain(domain):
22+
expiry = cert_expiries.get(domain)
23+
return None if expiry is None else (domain, Mock(), [_cert(expiry)])
24+
25+
key_store.find_key_and_cert_by_cert_id.side_effect = by_cert_id
26+
key_store.find_key_and_cert_by_domain.side_effect = by_domain
27+
del key_store.find_key_and_cert_covering_domain
28+
29+
backend_info = SimpleNamespace(
30+
backend=Mock(),
31+
key_store=key_store,
32+
certapi_url="https://certapi.example.com",
33+
use_certapi_server=True,
34+
batch_domains=True,
35+
cert_manager=None,
36+
certapi_client=Mock(),
37+
challenge_store=None,
38+
)
39+
server = SimpleNamespace(config={"certapi": {"url": "https://certapi.example.com"}}, enqueue_reload=Mock())
40+
nginx = SimpleNamespace(challenge_dir="./.run_data/acme-challenges/")
41+
42+
with (
43+
patch(
44+
"nginx_proxy.post_processors.ssl_certificate_processor.build_certificate_backend",
45+
return_value=backend_info,
46+
),
47+
patch("nginx_proxy.post_processors.ssl_certificate_processor.RenewalManager") as renewal_cls,
48+
):
49+
renewal = Mock()
50+
renewal.update_threshold_secs = threshold_days * 24 * 3600
51+
renewal.sleep_slack_seconds = 300
52+
renewal_cls.return_value = renewal
53+
processor = SslCertificateProcessor(
54+
nginx, server=server, update_threshold_days=threshold_days, ssl_dir="./.run_data"
55+
)
56+
return processor, renewal
57+
58+
59+
def test_status_table_lists_each_domain_with_remaining_time_and_state(capsys):
60+
now = datetime.now(timezone.utc)
61+
processor, _ = _build_processor(
62+
{
63+
"*.example.com": now + timedelta(days=80, hours=3, minutes=4, seconds=5),
64+
"api.example.com": now + timedelta(days=80),
65+
"old.example.net": now - timedelta(days=2),
66+
"soon.example.net": now + timedelta(days=5),
67+
}
68+
)
69+
hosts = [
70+
Host("api.example.com", 443, {"https"}),
71+
Host("old.example.net", 443, {"https"}),
72+
Host("soon.example.net", 443, {"https"}),
73+
Host("new.example.net", 443, {"https"}),
74+
]
75+
76+
processor.process_ssl_certificates(hosts)
77+
lines = capsys.readouterr().out.splitlines()
78+
79+
assert "[SSL Refresh Thread] SSL certificate status:" in lines
80+
rows = {line.split(" - ", 1)[0].strip(): line.split(" - ", 1)[1] for line in lines if line.startswith(" ")}
81+
# every hostname is padded to the same column
82+
assert len({line.index(" - ") for line in lines if line.startswith(" ")}) == 1
83+
# no microseconds, zero padded time
84+
assert rows["api.example.com"].startswith("80 days, 03:04:0") and rows["api.example.com"].endswith(
85+
"(*.example.com)"
86+
)
87+
assert rows["old.example.net"].startswith("EXPIRED 2 days, 00:00:0") and rows["old.example.net"].endswith(" ago")
88+
assert rows["soon.example.net"].startswith("4 days, 23:59:5")
89+
assert "new.example.net" not in rows
90+
assert lines[-1] == "[SSL Refresh Thread] Selfsigned: new.example.net"
91+
# Earliest real certificate is soon.example.net, already inside the threshold -> refresh needed.
92+
assert any(
93+
line.startswith(
94+
"[SSL Refresh Thread] Looks like we need to refresh certificates that are about to expire (soon.example.net"
95+
)
96+
for line in lines
97+
)
98+
99+
100+
def test_next_check_is_expiry_minus_threshold_plus_slack(capsys):
101+
now = datetime.now(timezone.utc)
102+
processor, _ = _build_processor({"api.example.com": now + timedelta(days=40)})
103+
104+
processor.process_ssl_certificates([Host("api.example.com", 443, {"https"})])
105+
out = capsys.readouterr().out
106+
107+
# 40d - 30d threshold + 5m slack -> 10 days
108+
assert "[SSL Refresh Thread] All the certificates are up to date sleeping for 10 days." in out
109+
110+
111+
def test_sleep_is_capped_at_renewal_manager_max_sleep(capsys):
112+
now = datetime.now(timezone.utc)
113+
processor, renewal = _build_processor({"api.example.com": now + timedelta(days=89)})
114+
renewal.max_sleep_seconds = 32 * 24 * 3600
115+
116+
processor.process_ssl_certificates([Host("api.example.com", 443, {"https"})])
117+
118+
assert "sleeping for 32 days." in capsys.readouterr().out
119+
120+
121+
def test_status_is_only_logged_when_something_changed(capsys):
122+
now = datetime.now(timezone.utc)
123+
processor, _ = _build_processor({"api.example.com": now + timedelta(days=40)})
124+
hosts = [Host("api.example.com", 443, {"https"})]
125+
126+
processor.process_ssl_certificates(hosts)
127+
first = capsys.readouterr().out
128+
assert "[SSL Refresh Thread] SSL certificate status:" in first
129+
130+
processor.process_ssl_certificates(hosts)
131+
assert "SSL certificate status" not in capsys.readouterr().out
132+
133+
processor.process_ssl_certificates(hosts + [Host("www.example.com", 443, {"https"})])
134+
third = capsys.readouterr().out
135+
assert "SSL certificate status" in third
136+
assert "[SSL Refresh Thread] Selfsigned: www.example.com" in third
137+
138+
processor.log_certificate_status(hosts, force=True)
139+
assert "SSL certificate status" in capsys.readouterr().out
140+
141+
142+
def test_dry_run_does_not_log_status(capsys):
143+
now = datetime.now(timezone.utc)
144+
processor, _ = _build_processor({"api.example.com": now + timedelta(days=40)})
145+
146+
processor.process_ssl_certificates([Host("api.example.com", 443, {"https"})], update_watch_domains=False)
147+
148+
assert "SSL certificate status" not in capsys.readouterr().out
149+
150+
151+
def test_start_announces_thread_and_next_check(capsys):
152+
now = datetime.now(timezone.utc)
153+
processor, renewal = _build_processor({"api.example.com": now + timedelta(days=40)})
154+
processor.process_ssl_certificates([Host("api.example.com", 443, {"https"})])
155+
capsys.readouterr()
156+
157+
processor.start()
158+
out = capsys.readouterr().out
159+
160+
renewal.start.assert_called_once_with()
161+
assert (
162+
"[SSL Refresh Thread] Started with backend=certapi https://certapi.example.com, "
163+
"renew threshold 30 days, watching 1 domains"
164+
) in out
165+
assert "[SSL Refresh Thread] All the certificates are up to date sleeping for 10 days." in out
166+
167+
168+
def test_start_without_certificates_reports_nothing_to_watch(capsys):
169+
processor, _ = _build_processor({})
170+
171+
processor.start()
172+
173+
assert "[SSL Refresh Thread] Looks like there are no ssl certificates, sleeping until there's one" in (
174+
capsys.readouterr().out
175+
)

0 commit comments

Comments
 (0)