Skip to content

Commit 6f2fe45

Browse files
committed
fix(dstack-ingress): renew one lineage per run, and size the timeout to the wait
Two problems that compound each other. `certbot renew` renews *every* lineage in /etc/letsencrypt/renewal unless given --cert-name, and we never gave it one. run_pass calls this once per domain, so N domains meant N runs each covering all N lineages -- and renew_certificate then reported the result against whichever domain happened to ask, so domain A could be recorded as renewed because domain B was. Scope each run to the lineage it is named for. The run timeout was a hard-coded 300s. The dominant term inside a run is the DNS propagation wait, which the plugin sleeps through in-process -- and linode's own CERTBOT_PROPAGATION_SECONDS is 300, so on linode the cap could never be met and dns-01 timed out every time, on issuance as well as renewal. Before --cert-name it was worse still: the cap covered all lineages at once, so three cloudflare domains (120s each) could not fit either. Size it from the wait, and let CERTBOT_TIMEOUT override. --cert-name does not change the no-op wording renew_certificate parses: "No renewals were attempted." comes from _renew_describe_results, which runs for `renew` whether or not the lineage set was filtered. Checked against certbot 5.7.0, the version the image installs. Tested: 11 new cases in scripts/tests/test_certman.py covering scope (plain, wildcard, delegation, and certonly staying on -d) and the timeout (per-provider, delegation, override, invalid override, and a provider with no propagation setting). All five that assert the new behaviour fail against the previous code.
1 parent ea44c2c commit 6f2fe45

3 files changed

Lines changed: 177 additions & 4 deletions

File tree

custom-domain/dstack-ingress/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,8 @@ environment:
192192
| `EVIDENCE_PORT` | `80` | Internal port for evidence HTTP server |
193193
| `ALPN` | | TLS ALPN protocols (e.g. `h2,http/1.1`). Only set if backends support h2c |
194194
| `DELEGATION_ZONE` | | Zone this container writes into, so the DNS token needs no access to the served domain's own zone (see below) |
195-
| `DELEGATION_PROPAGATION_SECONDS` | `120` | Wait after writing the delegated challenge TXT before validation. Must outlast the record TTL (60s), or a resolver still serving the previous attempt's value fails validation. Keep well under ~250s — certbot is killed after a 300s per-run timeout |
195+
| `DELEGATION_PROPAGATION_SECONDS` | `120` | Wait after writing the delegated challenge TXT before validation. Must outlast the record TTL (60s), or a resolver still serving the previous attempt's value fails validation. The certbot run timeout is sized from this, so raising it is safe |
196+
| `CERTBOT_TIMEOUT` | propagation wait + 180s | Seconds a single certbot run may take. The default is derived from the provider's propagation wait (or `DELEGATION_PROPAGATION_SECONDS`), which is what dominates it; set this only if a run needs longer still |
196197

197198
For DNS provider credentials, see [DNS_PROVIDERS.md](DNS_PROVIDERS.md).
198199

custom-domain/dstack-ingress/scripts/certman.py

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ def staging_enabled() -> bool:
2424
return value == "true"
2525

2626

27+
# Time a certbot run needs on top of the DNS propagation wait: ACME round
28+
# trips, plugin setup, the cleanup hook, and certbot's own bookkeeping.
29+
CERTBOT_TIMEOUT_HEADROOM = 180
30+
31+
2732
class CertManager:
2833
"""Certificate management using DNS provider infrastructure."""
2934

@@ -303,6 +308,8 @@ def _build_certbot_command(self, action: str, domain: str, email: str) -> List[s
303308
# For `renew`, certbot reuses the authenticator + hooks saved in the
304309
# renewal config from the initial `certonly`, so we don't re-specify
305310
# them here (and must not fall back to the DNS plugin).
311+
if action == "renew":
312+
base_cmd.extend(["--cert-name", self._lineage_name(domain)])
306313
if staging_enabled():
307314
base_cmd.append("--staging")
308315
masked = [a if not (i > 0 and base_cmd[i - 1] == "--email") else "<email>"
@@ -342,6 +349,12 @@ def _build_certbot_command(self, action: str, domain: str, email: str) -> List[s
342349
else:
343350
base_cmd.append("--register-unsafely-without-email")
344351
base_cmd.extend(["-d", domain])
352+
if action == "renew":
353+
# Without this, `certbot renew` renews *every* lineage in
354+
# /etc/letsencrypt/renewal. run_pass calls this once per domain, so
355+
# N domains meant N passes over all N lineages, and the result was
356+
# then reported against whichever domain happened to ask.
357+
base_cmd.extend(["--cert-name", self._lineage_name(domain)])
345358
if staging_enabled():
346359
base_cmd.extend(["--staging"])
347360

@@ -380,10 +393,11 @@ def obtain_certificate(self, domain: str, email: str) -> bool:
380393
return False
381394

382395
cmd = self._build_certbot_command("certonly", domain, email)
396+
timeout = self._certbot_timeout()
383397

384398
try:
385399
result = subprocess.run(
386-
cmd, capture_output=True, text=True, timeout=300)
400+
cmd, capture_output=True, text=True, timeout=timeout)
387401

388402
if result.returncode == 0:
389403
print(f"✓ Certificate obtained successfully for {domain}")
@@ -412,7 +426,8 @@ def obtain_certificate(self, domain: str, email: str) -> bool:
412426
return False
413427

414428
except subprocess.TimeoutExpired:
415-
print(f"Certbot command timed out after 300 seconds", file=sys.stderr)
429+
print(f"Certbot command timed out after {timeout} seconds",
430+
file=sys.stderr)
416431
return False
417432
except Exception as e:
418433
print(f"Error running certbot: {e}", file=sys.stderr)
@@ -433,10 +448,11 @@ def renew_certificate(self, domain: str) -> Tuple[bool, bool]:
433448

434449
self.apply_renewal_window(domain)
435450
cmd = self._build_certbot_command("renew", domain, "")
451+
timeout = self._certbot_timeout()
436452

437453
try:
438454
result = subprocess.run(
439-
cmd, capture_output=True, text=True, timeout=300)
455+
cmd, capture_output=True, text=True, timeout=timeout)
440456

441457
stdout_output = result.stdout.strip() if result.stdout else ""
442458
error_output = result.stderr.strip() if result.stderr else ""
@@ -468,6 +484,14 @@ def renew_certificate(self, domain: str) -> Tuple[bool, bool]:
468484

469485
return False, False
470486

487+
except subprocess.TimeoutExpired:
488+
print(
489+
f"Certbot renew for {domain} timed out after {timeout} seconds. "
490+
f"If this provider needs a longer DNS propagation wait, raise "
491+
f"CERTBOT_TIMEOUT.",
492+
file=sys.stderr,
493+
)
494+
return False, False
471495
except Exception as e:
472496
print(f"Error running certbot: {e}", file=sys.stderr)
473497
return False, False
@@ -486,6 +510,31 @@ def _renewal_conf_path(self, domain: str) -> str:
486510
"""Where certbot keeps this lineage's renewal config."""
487511
return f"/etc/letsencrypt/renewal/{self._lineage_name(domain)}.conf"
488512

513+
def _certbot_timeout(self) -> int:
514+
"""How long one certbot run may take.
515+
516+
The dominant term is the DNS propagation wait, which the plugin -- or,
517+
under delegation, the auth hook -- sleeps through inside the process.
518+
A single fixed cap cannot fit every provider: linode's own default
519+
propagation is 300s, so a 300s cap could never be met and dns-01 on
520+
linode timed out every time, on issuance as well as renewal.
521+
522+
Size the cap from the wait instead, and let a deployment override it.
523+
"""
524+
override = os.environ.get("CERTBOT_TIMEOUT", "").strip()
525+
if override.isdigit() and int(override) > 0:
526+
return int(override)
527+
528+
if os.environ.get("DELEGATION_ZONE", "").strip():
529+
# Kept in step with acme-dns-alias-hook.sh, which does the sleeping.
530+
wait = os.environ.get("DELEGATION_PROPAGATION_SECONDS", "").strip()
531+
propagation = int(wait) if wait.isdigit() else 120
532+
else:
533+
propagation = getattr(
534+
self.provider, "CERTBOT_PROPAGATION_SECONDS", None) or 0
535+
536+
return propagation + CERTBOT_TIMEOUT_HEADROOM
537+
489538
def apply_renewal_window(self, domain: str) -> None:
490539
"""Make RENEW_DAYS_BEFORE mean the same thing here as it does for lego.
491540

custom-domain/dstack-ingress/scripts/tests/test_certman.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,129 @@ def test_wildcard_uses_the_bare_lineage_name(self):
123123
)
124124

125125

126+
class FakeProvider:
127+
CERTBOT_PLUGIN = "dns-cloudflare"
128+
CERTBOT_CREDENTIALS_FILE = None
129+
CERTBOT_PROPAGATION_SECONDS = 120
130+
131+
132+
def _command_manager(propagation=120) -> certman.CertManager:
133+
"""A CertManager that can build commands without a real provider or venv."""
134+
mgr = object.__new__(certman.CertManager)
135+
provider = FakeProvider()
136+
provider.CERTBOT_PROPAGATION_SECONDS = propagation
137+
mgr.provider = provider
138+
mgr.provider_type = "cloudflare"
139+
mgr._get_certbot_command = lambda: ["certbot"] # noqa: SLF001
140+
return mgr
141+
142+
143+
class EnvTestCase(unittest.TestCase):
144+
"""Restore every environment variable a test touches."""
145+
146+
VARS = (
147+
"CERTBOT_TIMEOUT",
148+
"DELEGATION_ZONE",
149+
"DELEGATION_PROPAGATION_SECONDS",
150+
"ACME_STAGING",
151+
"CERTBOT_STAGING",
152+
)
153+
154+
def setUp(self):
155+
self._saved = {v: os.environ.get(v) for v in self.VARS}
156+
for v in self.VARS:
157+
os.environ.pop(v, None)
158+
159+
def tearDown(self):
160+
for v, old in self._saved.items():
161+
if old is None:
162+
os.environ.pop(v, None)
163+
else:
164+
os.environ[v] = old
165+
166+
167+
class RenewScopeTest(EnvTestCase):
168+
"""`certbot renew` renews every lineage unless told otherwise.
169+
170+
run_pass calls this once per domain, so without --cert-name N domains meant
171+
N runs over all N lineages, and each run's result was reported against
172+
whichever domain happened to ask for it.
173+
"""
174+
175+
def test_renew_is_scoped_to_one_lineage(self):
176+
cmd = _command_manager()._build_certbot_command("renew", "a.example.com", "")
177+
self.assertIn("--cert-name", cmd)
178+
self.assertEqual(cmd[cmd.index("--cert-name") + 1], "a.example.com")
179+
180+
def test_renew_uses_the_bare_name_for_a_wildcard(self):
181+
cmd = _command_manager()._build_certbot_command("renew", "*.example.com", "")
182+
self.assertEqual(cmd[cmd.index("--cert-name") + 1], "example.com")
183+
184+
def test_certonly_is_scoped_by_d_not_cert_name(self):
185+
cmd = _command_manager()._build_certbot_command(
186+
"certonly", "a.example.com", "")
187+
self.assertNotIn("--cert-name", cmd)
188+
self.assertIn("-d", cmd)
189+
190+
def test_delegation_renew_is_scoped_too(self):
191+
os.environ["DELEGATION_ZONE"] = "deleg.example.net"
192+
cmd = _command_manager()._build_certbot_command("renew", "a.example.com", "")
193+
self.assertEqual(cmd[cmd.index("--cert-name") + 1], "a.example.com")
194+
# renew must not re-specify the authenticator; it is in the lineage.
195+
self.assertNotIn("--manual", cmd)
196+
197+
198+
class CertbotTimeoutTest(EnvTestCase):
199+
"""A fixed 300s cap could not fit every provider's own propagation wait."""
200+
201+
def test_timeout_covers_the_propagation_wait(self):
202+
self.assertEqual(
203+
_command_manager(propagation=120)._certbot_timeout(),
204+
120 + certman.CERTBOT_TIMEOUT_HEADROOM,
205+
)
206+
207+
def test_linode_default_propagation_fits(self):
208+
# linode's CERTBOT_PROPAGATION_SECONDS is 300, so the old 300s cap could
209+
# never be met: issuance and renewal timed out every time.
210+
timeout = _command_manager(propagation=300)._certbot_timeout()
211+
self.assertGreater(timeout, 300)
212+
213+
def test_delegation_uses_its_own_propagation_setting(self):
214+
os.environ["DELEGATION_ZONE"] = "deleg.example.net"
215+
os.environ["DELEGATION_PROPAGATION_SECONDS"] = "200"
216+
self.assertEqual(
217+
_command_manager()._certbot_timeout(),
218+
200 + certman.CERTBOT_TIMEOUT_HEADROOM,
219+
)
220+
221+
def test_delegation_falls_back_to_the_hook_default(self):
222+
os.environ["DELEGATION_ZONE"] = "deleg.example.net"
223+
self.assertEqual(
224+
_command_manager()._certbot_timeout(),
225+
120 + certman.CERTBOT_TIMEOUT_HEADROOM,
226+
)
227+
228+
def test_explicit_override_wins(self):
229+
os.environ["CERTBOT_TIMEOUT"] = "900"
230+
self.assertEqual(_command_manager(propagation=300)._certbot_timeout(), 900)
231+
232+
def test_invalid_override_is_ignored(self):
233+
for bad in ("0", "-5", "soon", ""):
234+
os.environ["CERTBOT_TIMEOUT"] = bad
235+
self.assertEqual(
236+
_command_manager(propagation=120)._certbot_timeout(),
237+
120 + certman.CERTBOT_TIMEOUT_HEADROOM,
238+
f"{bad!r} should be ignored",
239+
)
240+
241+
def test_provider_without_propagation_still_gets_headroom(self):
242+
# route53 sets CERTBOT_PROPAGATION_SECONDS = None.
243+
self.assertEqual(
244+
_command_manager(propagation=None)._certbot_timeout(),
245+
certman.CERTBOT_TIMEOUT_HEADROOM,
246+
)
247+
248+
126249
class NoDuplicateDefinitionsTest(unittest.TestCase):
127250
"""Python shadows a repeated class or method silently; unittest counts the
128251
later one and the total still goes up, so a duplicated block looks like

0 commit comments

Comments
 (0)