Skip to content

Commit 32e8481

Browse files
committed
Log certificate status and stop renewal reload storm
Print the SSL Refresh Thread status table (domain, time left, self-signed list, next check) when the watch set changes and when the renewal thread starts. Queue only one forced reload per renewal trigger while the renewal pass is in flight, and force a reload when a certificate file already in use is replaced during an ordinary reload. Require certapi 1.1.16, which stops re-signalling the callback every second.
1 parent bf86a99 commit 32e8481

4 files changed

Lines changed: 137 additions & 3 deletions

File tree

nginx_proxy/WebServer.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,10 @@ def _do_reload(self, forced=False, validate=True) -> bool:
251251
"""
252252
# print("web_server._do_reload(forced="+str(forced)+")")
253253
output = self._render_config(self.config_data, update_ssl_watch_domains=True)
254+
# Backstop for renewals that happen inside an ordinary (non-forced) reload, e.g. a docker event
255+
# whose pass found a due certificate: the file contents changed but the rendered text did not.
256+
if self.ssl_processor.pop_certificate_changes():
257+
forced = True
254258
response = self.nginx.update_config(output, force=forced, validate=validate)
255259
return response
256260

nginx_proxy/post_processors/ssl_certificate_processor.py

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import threading
12
from datetime import datetime, timedelta, timezone
23
from typing import List, Optional, Tuple
34

@@ -39,6 +40,14 @@ def __init__(
3940
)
4041
self._status_rows: List[Tuple[str, str, Optional[datetime]]] = []
4142
self._last_logged_status: Optional[Tuple] = None
43+
# The renewal worker may invoke the callback repeatedly (once a second) while a renewal pass is
44+
# still in flight on the reload thread. Only one forced reload is queued until that reload has run.
45+
self._renewal_reload_lock = threading.Lock()
46+
self._renewal_reload_pending = False
47+
# Expiry of every certificate file seen in the last pass, used to detect files replaced by a
48+
# renewal so that nginx is reloaded even when the rendered configuration text is unchanged.
49+
self._known_file_expiry: dict = {}
50+
self._certificates_changed = False
4251

4352
if start_ssl_thread:
4453
self.start()
@@ -54,10 +63,21 @@ def start(self):
5463
self._log_next_check()
5564

5665
def ssl_renewal_callback(self):
57-
print("[SSL] Renewal callback triggered")
5866
if self.server is None:
5967
return
60-
self.server.enqueue_reload(force=True)
68+
with self._renewal_reload_lock:
69+
if self._renewal_reload_pending:
70+
return
71+
self._renewal_reload_pending = True
72+
print("[SSL Refresh Thread] Renewal due, requesting forced nginx reload")
73+
try:
74+
# Forced on purpose: a renewal replaces certificate file contents, not paths, so the rendered
75+
# config is byte-identical and a plain reload would be skipped by the config diff.
76+
self.server.enqueue_reload(force=True)
77+
except Exception:
78+
with self._renewal_reload_lock:
79+
self._renewal_reload_pending = False
80+
raise
6181

6282
def _find_certificate_for_domain(self, domain: str) -> None | Tuple[str, Key, List[Certificate]]:
6383
if hasattr(self.key_store, "find_key_and_cert_covering_domain"):
@@ -127,13 +147,38 @@ def process_ssl_certificates(self, hosts: List[Host], update_watch_domains: bool
127147

128148
secured_domains = sorted({host.hostname for host in secured_hosts})
129149
if update_watch_domains:
130-
self.renewal_manager.update_watch_domains(secured_domains)
150+
try:
151+
self.renewal_manager.update_watch_domains(secured_domains)
152+
finally:
153+
with self._renewal_reload_lock:
154+
self._renewal_reload_pending = False
131155

132156
for host in secured_hosts:
133157
host.ssl_file = self._select_ssl_file(host)
134158

135159
if update_watch_domains:
136160
self.log_certificate_status(secured_hosts)
161+
self._detect_certificate_changes()
162+
163+
def _detect_certificate_changes(self):
164+
"""Flag a forced reload when a certificate file already in use now carries a different expiry."""
165+
current = {}
166+
for _domain, ssl_file, expiry in self._status_rows:
167+
if ssl_file and expiry is not None:
168+
current[ssl_file] = expiry
169+
changed = [f for f, e in current.items() if f in self._known_file_expiry and self._known_file_expiry[f] != e]
170+
if changed:
171+
print(
172+
f"[SSL Refresh Thread] Certificates renewed on disk, nginx reload required: {', '.join(sorted(changed))}"
173+
)
174+
self._certificates_changed = True
175+
self._known_file_expiry = current
176+
177+
def pop_certificate_changes(self) -> bool:
178+
"""Return True once if certificate files changed since the last call, then reset."""
179+
changed = self._certificates_changed
180+
self._certificates_changed = False
181+
return changed
137182

138183
# ------------------------------------------------------------------
139184
# Status logging

tests/unit/test_ssl_status_logging.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,3 +173,74 @@ def test_start_without_certificates_reports_nothing_to_watch(capsys):
173173
assert "[SSL Refresh Thread] Looks like there are no ssl certificates, sleeping until there's one" in (
174174
capsys.readouterr().out
175175
)
176+
177+
178+
def test_renewal_callback_queues_only_one_reload_until_it_has_run(capsys):
179+
now = datetime.now(timezone.utc)
180+
processor, _ = _build_processor({"api.example.com": now + timedelta(days=40)})
181+
hosts = [Host("api.example.com", 443, {"https"})]
182+
183+
# The renewal worker ticks once a second while a renewal pass is in flight.
184+
for _ in range(5):
185+
processor.ssl_renewal_callback()
186+
187+
processor.server.enqueue_reload.assert_called_once_with(force=True)
188+
assert capsys.readouterr().out.count("Renewal due, requesting forced nginx reload") == 1
189+
190+
# The queued reload runs and refreshes the watch set, after which a new renewal may queue again.
191+
processor.process_ssl_certificates(hosts)
192+
processor.ssl_renewal_callback()
193+
assert processor.server.enqueue_reload.call_count == 2
194+
195+
196+
def test_renewal_callback_pending_flag_is_released_when_enqueue_fails():
197+
now = datetime.now(timezone.utc)
198+
processor, _ = _build_processor({"api.example.com": now + timedelta(days=40)})
199+
processor.server.enqueue_reload.side_effect = [RuntimeError("queue closed"), True]
200+
201+
try:
202+
processor.ssl_renewal_callback()
203+
except RuntimeError:
204+
pass
205+
processor.ssl_renewal_callback()
206+
207+
assert processor.server.enqueue_reload.call_count == 2
208+
209+
210+
def test_certificate_change_is_flagged_once_when_a_used_file_gets_a_new_expiry(capsys):
211+
now = datetime.now(timezone.utc)
212+
expiries = {"api.example.com": now + timedelta(days=20), "*.example.org": now + timedelta(days=50)}
213+
processor, _ = _build_processor(expiries)
214+
hosts = [Host("api.example.com", 443, {"https"}), Host("www.example.org", 443, {"https"})]
215+
216+
processor.process_ssl_certificates(hosts)
217+
assert processor.pop_certificate_changes() is False # first sight of the files is not a change
218+
219+
processor.process_ssl_certificates(hosts)
220+
assert processor.pop_certificate_changes() is False # nothing renewed
221+
222+
expiries["api.example.com"] = now + timedelta(days=89) # renewal replaced the file on disk
223+
processor.process_ssl_certificates(hosts)
224+
out = capsys.readouterr().out
225+
assert "Certificates renewed on disk, nginx reload required: api.example.com" in out
226+
assert processor.pop_certificate_changes() is True
227+
assert processor.pop_certificate_changes() is False # consumed
228+
229+
230+
def test_new_or_dry_run_files_do_not_flag_a_certificate_change():
231+
now = datetime.now(timezone.utc)
232+
expiries = {"api.example.com": now + timedelta(days=60)}
233+
processor, _ = _build_processor(expiries)
234+
processor.process_ssl_certificates([Host("api.example.com", 443, {"https"})])
235+
processor.pop_certificate_changes()
236+
237+
# A domain appearing for the first time changes the rendered config anyway, so no force needed.
238+
expiries["new.example.com"] = now + timedelta(days=60)
239+
hosts = [Host("api.example.com", 443, {"https"}), Host("new.example.com", 443, {"https"})]
240+
processor.process_ssl_certificates(hosts)
241+
assert processor.pop_certificate_changes() is False
242+
243+
# Dry runs never touch the tracking state.
244+
expiries["api.example.com"] = now + timedelta(days=89)
245+
processor.process_ssl_certificates(hosts, update_watch_domains=False)
246+
assert processor.pop_certificate_changes() is False

tests/unit/test_web_server.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,3 +632,17 @@ def test_rescan_prefer_local_keeps_health_gate_for_swarm_task(web_server):
632632
web_server.rescan_all_container(bypass_start_grace=True)
633633

634634
mock_register.assert_not_called()
635+
636+
637+
def test_do_reload_forces_nginx_reload_only_when_certificate_files_changed(web_server):
638+
web_server.ssl_processor.pop_certificate_changes.return_value = False
639+
web_server._do_reload(forced=False)
640+
assert web_server.nginx.update_config.call_args.kwargs["force"] is False
641+
642+
web_server.ssl_processor.pop_certificate_changes.return_value = True
643+
web_server._do_reload(forced=False)
644+
assert web_server.nginx.update_config.call_args.kwargs["force"] is True
645+
646+
web_server.ssl_processor.pop_certificate_changes.return_value = False
647+
web_server._do_reload(forced=True)
648+
assert web_server.nginx.update_config.call_args.kwargs["force"] is True

0 commit comments

Comments
 (0)