Skip to content

Commit abda739

Browse files
committed
Fix ssl renewal callback
1 parent 9e4272f commit abda739

8 files changed

Lines changed: 75 additions & 60 deletions

File tree

nginx_proxy/DockerEventListener.py

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,6 @@ class Reload:
7373
force: bool = False
7474

7575

76-
@dataclass(frozen=True)
77-
class SyncSslWatchDomains:
78-
pass
79-
80-
8176
_STOP = object()
8277

8378

@@ -184,8 +179,6 @@ def _dispatch(self, command):
184179
self.web_server._do_reload(command.force)
185180
elif isinstance(command, Reload):
186181
self.web_server._do_reload(command.force)
187-
elif isinstance(command, SyncSslWatchDomains):
188-
self._sync_ssl_watch_domains()
189182
elif callable(command):
190183
command()
191184
else:
@@ -451,12 +444,6 @@ def _process_backend_activation(self, container_id: str, generation: int):
451444
self._pending_backend_generations.pop(container_id, None)
452445
self._activate_backend_if_running(container_id)
453446

454-
def _sync_ssl_watch_domains(self):
455-
try:
456-
self.web_server._do_reload(True)
457-
finally:
458-
self.web_server.ssl_processor._dispatcher_ssl_reload_pending = False
459-
460447
@staticmethod
461448
def _container_has_healthcheck(container) -> bool:
462449
healthcheck = container.attrs.get("Config", {}).get("Healthcheck")
@@ -497,7 +484,8 @@ def _container_name(self, container=None, container_id: str | None = None, attri
497484
return None
498485
except (KeyboardInterrupt, SystemExit):
499486
raise
500-
except Exception:
487+
except Exception as e:
488+
self._log_unexpected_error(f"Could not resolve container name for {container_id}", e)
501489
return None
502490
name = getattr(resolved, "name", None) or resolved.attrs.get("Name")
503491
return name.lstrip("/") if isinstance(name, str) else None
@@ -532,10 +520,12 @@ def _should_forward_network_connect(self, container_id: str) -> bool:
532520
except docker.errors.NotFound:
533521
return False
534522
except ValueError:
523+
print(f"WARN: Ignoring network connect for invalid container id {container_id!r}", file=sys.stderr)
535524
return False
536525
except (KeyboardInterrupt, SystemExit):
537526
raise
538-
except Exception:
527+
except Exception as e:
528+
self._log_unexpected_error(f"Could not inspect container {container_id} for network connect", e)
539529
return False
540530

541531
if not self._container_is_running(container):
@@ -563,5 +553,11 @@ def _load_started_container_ids(self) -> set[str]:
563553
}
564554
except (KeyboardInterrupt, SystemExit):
565555
raise
566-
except Exception:
556+
except Exception as e:
557+
self._log_unexpected_error("Could not load running containers during Docker event listener startup", e)
567558
return set()
559+
560+
@staticmethod
561+
def _log_unexpected_error(message: str, error: Exception):
562+
print(f"WARN: {message}: {error.__class__.__name__} -> {error}", file=sys.stderr)
563+
traceback.print_exc(limit=10)

nginx_proxy/WebServer.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -240,17 +240,17 @@ def reload(self, immediate=False, force=False) -> bool:
240240
Returns True if a reload was initiated or scheduled.
241241
"""
242242

243-
def run_or_enqueue():
244-
if self._reload_dispatcher is None:
245-
return self._do_reload(force)
246-
if self._is_reload_dispatcher_thread is not None and self._is_reload_dispatcher_thread():
247-
return self._do_reload(force)
243+
return self.throttler.throttle(lambda: self._do_reload(force), immediate=immediate or force)
248244

249-
from nginx_proxy.DockerEventListener import Reload
245+
def enqueue_reload(self, force=False) -> bool:
246+
if self._reload_dispatcher is None:
247+
return self.reload(immediate=force, force=force)
248+
if self._is_reload_dispatcher_thread is not None and self._is_reload_dispatcher_thread():
249+
return self.reload(immediate=force, force=force)
250250

251-
return self._reload_dispatcher(Reload(force))
251+
from nginx_proxy.DockerEventListener import Reload
252252

253-
return self.throttler.throttle(run_or_enqueue, immediate=immediate or force)
253+
return self._reload_dispatcher(Reload(force))
254254

255255
def disconnect(self, network, container, scope):
256256
if self.id is not None and container == self.id:

nginx_proxy/post_processors/ssl_certificate_processor.py

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,9 @@ def __init__(
3030
self.cert_manager = backend_info.cert_manager
3131
self.certapi_client = backend_info.certapi_client
3232
self.challenge_store = backend_info.challenge_store
33-
self._dispatcher_ssl_reload_pending = False
3433
self.renewal_manager = RenewalManager(
3534
self.backend,
36-
renewal_callback=self.sync_watch_domains,
35+
renewal_callback=self.ssl_renewal_callback,
3736
renew_threshold_days=max(1, int(self.update_threshold_secs // (24 * 3600))),
3837
batch_domains=self.certapi_batch_domains,
3938
)
@@ -44,21 +43,11 @@ def __init__(
4443
def start(self):
4544
self.renewal_manager.start()
4645

47-
def sync_watch_domains(self):
46+
def ssl_renewal_callback(self):
47+
print("[SSL] Renewal callback triggered")
4848
if self.server is None:
4949
return
50-
listener = getattr(self.server, "docker_event_listener", None)
51-
if listener is not None and listener.is_dispatcher_running():
52-
if self._dispatcher_ssl_reload_pending:
53-
return
54-
from nginx_proxy.DockerEventListener import SyncSslWatchDomains
55-
56-
self._dispatcher_ssl_reload_pending = True
57-
listener.enqueue(SyncSslWatchDomains())
58-
return
59-
domains = sorted({host.hostname for host in self.server.config_data.host_list() if host.secured})
60-
self.renewal_manager.update_watch_domains(domains)
61-
self.server.reload(force=True)
50+
self.server.enqueue_reload(force=True)
6251

6352
def is_certificate_fresh(self, domain: str, threshold_seconds: float | None = None) -> bool:
6453
result = self.key_store.find_key_and_cert_by_domain(domain)

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.10
5+
certapi>=1.1.11
66
requests==2.33.0

tests/conftest.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
def pytest_collection_modifyitems(items):
2+
original_order = {item: index for index, item in enumerate(items)}
3+
swarm_mode_order = {
4+
"enable": 0,
5+
"exclude": 1,
6+
"ignore": 2,
7+
"prefer-local": 3,
8+
"strict": 4,
9+
}
10+
11+
def swarm_mode_sort_key(item):
12+
callspec = getattr(item, "callspec", None)
13+
swarm_mode = callspec.params.get("swarm_mode") if callspec is not None else None
14+
if swarm_mode is None:
15+
return (0, 0, original_order[item])
16+
return (1, swarm_mode_order.get(swarm_mode, len(swarm_mode_order)), original_order[item])
17+
18+
items.sort(key=swarm_mode_sort_key)

tests/integration/test_nginx_proxy.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import requests
66
import websocket
77
import time
8+
import re
9+
import hashlib
810
from datetime import datetime, timezone
911
from unittest.mock import patch
1012

@@ -42,6 +44,12 @@ def _hostname_mode_token(swarm_mode):
4244
return {"prefer-local": "pl"}.get(swarm_mode, swarm_mode)
4345

4446

47+
def _hostname_slug(value):
48+
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
49+
digest = hashlib.sha1(value.encode("utf-8")).hexdigest()[:10]
50+
return f"{slug[:36].strip('-')}-{digest}"
51+
52+
4553
def _has_proxy_server(config_str, server_name):
4654
config = HttpBlock.parse(config_str)
4755
for server in config.servers:
@@ -125,7 +133,8 @@ def test_http_routing_discovery(
125133
"""
126134
Test HTTP routing discovery for various swarm modes and backend types.
127135
"""
128-
hostname = f"{backend_type}.{swarm_mode}.routing.example.com"
136+
case_slug = _hostname_slug(request.node.name)
137+
hostname = f"{backend_type}.{_hostname_mode_token(swarm_mode)}.{case_slug}.routing.example.com"
129138
should_be_reachable = is_reachable(swarm_mode, backend_type)
130139

131140
env = {"VIRTUAL_HOST": hostname + virtual_host_path, "VIRTUAL_PORT": "8080"}

tests/unit/test_ssl_certapi_batch_domains.py

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
from unittest.mock import Mock, patch
66

77
from nginx_proxy.Host import Host
8-
from nginx_proxy.DockerEventListener import SyncSslWatchDomains
98
from nginx_proxy.post_processors.ssl_certificate_processor import SslCertificateProcessor
109

1110

@@ -23,6 +22,7 @@ def _make_server():
2322
}
2423
},
2524
reload=Mock(),
25+
enqueue_reload=Mock(),
2626
)
2727

2828

@@ -79,7 +79,7 @@ def test_certapi_batch_domains_passed_to_renewal_manager(monkeypatch):
7979
assert processor.certapi_batch_domains is False
8080
backend.obtain.assert_not_called()
8181
assert processor._test_renewal_cls_call_args.kwargs["batch_domains"] is False
82-
assert processor._test_renewal_cls_call_args.kwargs["renewal_callback"] == processor.sync_watch_domains
82+
assert processor._test_renewal_cls_call_args.kwargs["renewal_callback"] == processor.ssl_renewal_callback
8383

8484

8585
def test_processor_does_not_obtain_directly_and_triggers_renewal_once(monkeypatch):
@@ -104,34 +104,25 @@ def test_ssl_starts_and_stops_certapi_renewal_manager(monkeypatch):
104104
renewal.stop.assert_called_once_with()
105105

106106

107-
def test_sync_watch_domains_publishes_secured_hosts_to_renewal_manager(monkeypatch):
108-
secured = Host("secure.example.com", 443, {"https"})
109-
wildcard = Host("*.example.com", 443, {"https"})
110-
plain = Host("plain.example.com", 80, {"http"})
107+
def test_ssl_renewal_callback_enqueues_server_reload(monkeypatch):
111108
server = _make_server()
112-
server.config_data = SimpleNamespace(host_list=lambda: [secured, plain, wildcard])
113109
processor, _backend, renewal = _build_processor(monkeypatch, None)
114110
processor.server = server
115111

116-
processor.sync_watch_domains()
112+
processor.ssl_renewal_callback()
117113

118-
renewal.update_watch_domains.assert_called_once_with(["*.example.com", "secure.example.com"])
119-
server.reload.assert_called_once_with(force=True)
114+
renewal.update_watch_domains.assert_not_called()
115+
server.enqueue_reload.assert_called_once_with(force=True)
116+
server.reload.assert_not_called()
120117

121118

122-
def test_sync_watch_domains_enqueues_when_dispatcher_is_running(monkeypatch):
123-
server = _make_server()
124-
listener = Mock()
125-
listener.is_dispatcher_running.return_value = True
126-
server.docker_event_listener = listener
119+
def test_ssl_renewal_callback_ignores_missing_server(monkeypatch):
127120
processor, _backend, renewal = _build_processor(monkeypatch, None)
128-
processor.server = server
121+
processor.server = None
129122

130-
processor.sync_watch_domains()
123+
processor.ssl_renewal_callback()
131124

132125
renewal.update_watch_domains.assert_not_called()
133-
server.reload.assert_not_called()
134-
listener.enqueue.assert_called_once_with(SyncSslWatchDomains())
135126

136127

137128
def test_getssl_force_passes_self_verify_false_to_remote_backend(monkeypatch, tmp_path):

tests/unit/test_web_server.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from datetime import datetime, timedelta, timezone
44

55
from nginx_proxy.BackendTarget import BackendTarget
6+
from nginx_proxy.DockerEventListener import Reload
67
from nginx_proxy.ProxyConfigData import ProxyConfigData
78
from nginx_proxy.WebServer import WebServer
89

@@ -206,6 +207,17 @@ def test_reload_force_runs_immediately(web_server):
206207
assert mock_throttle.call_args.kwargs["immediate"] is True
207208

208209

210+
def test_enqueue_reload_uses_dispatcher_when_running(web_server):
211+
dispatcher = MagicMock(return_value=True)
212+
web_server.set_reload_dispatcher(dispatcher, lambda: False)
213+
214+
assert web_server.enqueue_reload(force=True) is True
215+
216+
command = dispatcher.call_args.args[0]
217+
assert isinstance(command, Reload)
218+
assert command.force is True
219+
220+
209221
def test_should_register_container_now_skips_unhealthy_healthcheck(web_server):
210222
container = MagicMock()
211223
container.status = "running"

0 commit comments

Comments
 (0)