Skip to content

Commit b1a9040

Browse files
authored
Merge pull request #111 from Dstack-TEE/fix/ingress-renew-scope-and-timeout
fix(dstack-ingress): renew one lineage per run, and size the timeout to the wait
2 parents 19d4af0 + 4d62c57 commit b1a9040

3 files changed

Lines changed: 204 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, never below 300s | 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; an explicit value is used as given, floor included |
196197

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

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

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,17 @@ 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+
# Never allow less than the cap this replaced. route53 declares no propagation
32+
# wait at all, so sizing purely from the wait would cut its budget from 300s to
33+
# 180s and fail renewals that used to fit. The sizing exists to raise the cap
34+
# where a provider needs more, not to lower it anywhere.
35+
CERTBOT_TIMEOUT_FLOOR = 300
36+
37+
2738
class CertManager:
2839
"""Certificate management using DNS provider infrastructure."""
2940

@@ -303,6 +314,8 @@ def _build_certbot_command(self, action: str, domain: str, email: str) -> List[s
303314
# For `renew`, certbot reuses the authenticator + hooks saved in the
304315
# renewal config from the initial `certonly`, so we don't re-specify
305316
# them here (and must not fall back to the DNS plugin).
317+
if action == "renew":
318+
base_cmd.extend(["--cert-name", self._lineage_name(domain)])
306319
if staging_enabled():
307320
base_cmd.append("--staging")
308321
masked = [a if not (i > 0 and base_cmd[i - 1] == "--email") else "<email>"
@@ -342,6 +355,12 @@ def _build_certbot_command(self, action: str, domain: str, email: str) -> List[s
342355
else:
343356
base_cmd.append("--register-unsafely-without-email")
344357
base_cmd.extend(["-d", domain])
358+
if action == "renew":
359+
# Without this, `certbot renew` renews *every* lineage in
360+
# /etc/letsencrypt/renewal. run_pass calls this once per domain, so
361+
# N domains meant N passes over all N lineages, and the result was
362+
# then reported against whichever domain happened to ask.
363+
base_cmd.extend(["--cert-name", self._lineage_name(domain)])
345364
if staging_enabled():
346365
base_cmd.extend(["--staging"])
347366

@@ -380,10 +399,11 @@ def obtain_certificate(self, domain: str, email: str) -> bool:
380399
return False
381400

382401
cmd = self._build_certbot_command("certonly", domain, email)
402+
timeout = self._certbot_timeout()
383403

384404
try:
385405
result = subprocess.run(
386-
cmd, capture_output=True, text=True, timeout=300)
406+
cmd, capture_output=True, text=True, timeout=timeout)
387407

388408
if result.returncode == 0:
389409
print(f"✓ Certificate obtained successfully for {domain}")
@@ -412,7 +432,8 @@ def obtain_certificate(self, domain: str, email: str) -> bool:
412432
return False
413433

414434
except subprocess.TimeoutExpired:
415-
print(f"Certbot command timed out after 300 seconds", file=sys.stderr)
435+
print(f"Certbot command timed out after {timeout} seconds",
436+
file=sys.stderr)
416437
return False
417438
except Exception as e:
418439
print(f"Error running certbot: {e}", file=sys.stderr)
@@ -433,10 +454,11 @@ def renew_certificate(self, domain: str) -> Tuple[bool, bool]:
433454

434455
self.apply_renewal_window(domain)
435456
cmd = self._build_certbot_command("renew", domain, "")
457+
timeout = self._certbot_timeout()
436458

437459
try:
438460
result = subprocess.run(
439-
cmd, capture_output=True, text=True, timeout=300)
461+
cmd, capture_output=True, text=True, timeout=timeout)
440462

441463
stdout_output = result.stdout.strip() if result.stdout else ""
442464
error_output = result.stderr.strip() if result.stderr else ""
@@ -468,6 +490,14 @@ def renew_certificate(self, domain: str) -> Tuple[bool, bool]:
468490

469491
return False, False
470492

493+
except subprocess.TimeoutExpired:
494+
print(
495+
f"Certbot renew for {domain} timed out after {timeout} seconds. "
496+
f"If this provider needs a longer DNS propagation wait, raise "
497+
f"CERTBOT_TIMEOUT.",
498+
file=sys.stderr,
499+
)
500+
return False, False
471501
except Exception as e:
472502
print(f"Error running certbot: {e}", file=sys.stderr)
473503
return False, False
@@ -486,6 +516,35 @@ def _renewal_conf_path(self, domain: str) -> str:
486516
"""Where certbot keeps this lineage's renewal config."""
487517
return f"/etc/letsencrypt/renewal/{self._lineage_name(domain)}.conf"
488518

519+
def _certbot_timeout(self) -> int:
520+
"""How long one certbot run may take.
521+
522+
The dominant term is the DNS propagation wait, which the plugin -- or,
523+
under delegation, the auth hook -- sleeps through inside the process.
524+
A single fixed cap cannot fit every provider: linode's own default
525+
propagation is 300s, so a 300s cap could never be met and dns-01 on
526+
linode timed out every time, on issuance as well as renewal.
527+
528+
Size the cap from the wait instead, and let a deployment override it.
529+
Never below CERTBOT_TIMEOUT_FLOOR: a provider that declares no
530+
propagation wait (route53) still spends time on ACME round trips, and
531+
this change should not shorten anyone's budget. An explicit
532+
CERTBOT_TIMEOUT is taken literally -- that one is the operator's call.
533+
"""
534+
override = os.environ.get("CERTBOT_TIMEOUT", "").strip()
535+
if override.isdigit() and int(override) > 0:
536+
return int(override)
537+
538+
if os.environ.get("DELEGATION_ZONE", "").strip():
539+
# Kept in step with acme-dns-alias-hook.sh, which does the sleeping.
540+
wait = os.environ.get("DELEGATION_PROPAGATION_SECONDS", "").strip()
541+
propagation = int(wait) if wait.isdigit() else 120
542+
else:
543+
propagation = getattr(
544+
self.provider, "CERTBOT_PROPAGATION_SECONDS", None) or 0
545+
546+
return max(CERTBOT_TIMEOUT_FLOOR, propagation + CERTBOT_TIMEOUT_HEADROOM)
547+
489548
def apply_renewal_window(self, domain: str) -> None:
490549
"""Make RENEW_DAYS_BEFORE mean the same thing here as it does for lego.
491550

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

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,146 @@ def test_wildcard_uses_the_bare_lineage_name(self):
149149
)
150150

151151

152+
class FakeProvider:
153+
CERTBOT_PLUGIN = "dns-cloudflare"
154+
CERTBOT_CREDENTIALS_FILE = None
155+
CERTBOT_PROPAGATION_SECONDS = 120
156+
157+
158+
def _command_manager(propagation=120) -> certman.CertManager:
159+
"""A CertManager that can build commands without a real provider or venv."""
160+
mgr = object.__new__(certman.CertManager)
161+
provider = FakeProvider()
162+
provider.CERTBOT_PROPAGATION_SECONDS = propagation
163+
mgr.provider = provider
164+
mgr.provider_type = "cloudflare"
165+
mgr._get_certbot_command = lambda: ["certbot"] # noqa: SLF001
166+
return mgr
167+
168+
169+
class EnvTestCase(unittest.TestCase):
170+
"""Restore every environment variable a test touches."""
171+
172+
VARS = (
173+
"CERTBOT_TIMEOUT",
174+
"DELEGATION_ZONE",
175+
"DELEGATION_PROPAGATION_SECONDS",
176+
"ACME_STAGING",
177+
"CERTBOT_STAGING",
178+
)
179+
180+
def setUp(self):
181+
self._saved = {v: os.environ.get(v) for v in self.VARS}
182+
for v in self.VARS:
183+
os.environ.pop(v, None)
184+
185+
def tearDown(self):
186+
for v, old in self._saved.items():
187+
if old is None:
188+
os.environ.pop(v, None)
189+
else:
190+
os.environ[v] = old
191+
192+
193+
class RenewScopeTest(EnvTestCase):
194+
"""`certbot renew` renews every lineage unless told otherwise.
195+
196+
run_pass calls this once per domain, so without --cert-name N domains meant
197+
N runs over all N lineages, and each run's result was reported against
198+
whichever domain happened to ask for it.
199+
"""
200+
201+
def test_renew_is_scoped_to_one_lineage(self):
202+
cmd = _command_manager()._build_certbot_command("renew", "a.example.com", "")
203+
self.assertIn("--cert-name", cmd)
204+
self.assertEqual(cmd[cmd.index("--cert-name") + 1], "a.example.com")
205+
206+
def test_renew_uses_the_bare_name_for_a_wildcard(self):
207+
cmd = _command_manager()._build_certbot_command("renew", "*.example.com", "")
208+
self.assertEqual(cmd[cmd.index("--cert-name") + 1], "example.com")
209+
210+
def test_certonly_is_scoped_by_d_not_cert_name(self):
211+
cmd = _command_manager()._build_certbot_command(
212+
"certonly", "a.example.com", "")
213+
self.assertNotIn("--cert-name", cmd)
214+
self.assertIn("-d", cmd)
215+
216+
def test_delegation_renew_is_scoped_too(self):
217+
os.environ["DELEGATION_ZONE"] = "deleg.example.net"
218+
cmd = _command_manager()._build_certbot_command("renew", "a.example.com", "")
219+
self.assertEqual(cmd[cmd.index("--cert-name") + 1], "a.example.com")
220+
# renew must not re-specify the authenticator; it is in the lineage.
221+
self.assertNotIn("--manual", cmd)
222+
223+
224+
class CertbotTimeoutTest(EnvTestCase):
225+
"""A fixed 300s cap could not fit every provider's own propagation wait."""
226+
227+
def test_timeout_covers_the_propagation_wait(self):
228+
self.assertEqual(
229+
_command_manager(propagation=400)._certbot_timeout(),
230+
400 + certman.CERTBOT_TIMEOUT_HEADROOM,
231+
)
232+
233+
def test_linode_default_propagation_fits(self):
234+
# linode's CERTBOT_PROPAGATION_SECONDS is 300, so the old 300s cap could
235+
# never be met: issuance and renewal timed out every time.
236+
timeout = _command_manager(propagation=300)._certbot_timeout()
237+
self.assertGreater(timeout, 300)
238+
239+
def test_delegation_uses_its_own_propagation_setting(self):
240+
os.environ["DELEGATION_ZONE"] = "deleg.example.net"
241+
os.environ["DELEGATION_PROPAGATION_SECONDS"] = "240"
242+
self.assertEqual(
243+
_command_manager()._certbot_timeout(),
244+
240 + certman.CERTBOT_TIMEOUT_HEADROOM,
245+
)
246+
247+
def test_delegation_falls_back_to_the_hook_default(self):
248+
os.environ["DELEGATION_ZONE"] = "deleg.example.net"
249+
self.assertEqual(
250+
_command_manager()._certbot_timeout(),
251+
max(certman.CERTBOT_TIMEOUT_FLOOR,
252+
120 + certman.CERTBOT_TIMEOUT_HEADROOM),
253+
)
254+
255+
def test_explicit_override_wins(self):
256+
os.environ["CERTBOT_TIMEOUT"] = "900"
257+
self.assertEqual(_command_manager(propagation=300)._certbot_timeout(), 900)
258+
259+
def test_invalid_override_is_ignored(self):
260+
for bad in ("0", "-5", "soon", ""):
261+
os.environ["CERTBOT_TIMEOUT"] = bad
262+
self.assertEqual(
263+
_command_manager(propagation=400)._certbot_timeout(),
264+
400 + certman.CERTBOT_TIMEOUT_HEADROOM,
265+
f"{bad!r} should be ignored",
266+
)
267+
268+
def test_an_override_below_the_floor_is_still_honoured(self):
269+
os.environ["CERTBOT_TIMEOUT"] = "60"
270+
self.assertEqual(_command_manager(propagation=300)._certbot_timeout(), 60)
271+
272+
def test_provider_without_propagation_keeps_the_old_budget(self):
273+
"""route53 declares no propagation wait.
274+
275+
Sizing purely from the wait would cut it from 300s to 180s and fail
276+
renewals that used to fit -- a regression introduced by the fix.
277+
"""
278+
self.assertEqual(
279+
_command_manager(propagation=None)._certbot_timeout(),
280+
certman.CERTBOT_TIMEOUT_FLOOR,
281+
)
282+
283+
def test_no_provider_ends_up_below_the_previous_fixed_cap(self):
284+
for propagation in (None, 0, 30, 120, 300):
285+
self.assertGreaterEqual(
286+
_command_manager(propagation=propagation)._certbot_timeout(),
287+
300,
288+
f"propagation={propagation} shortened the budget",
289+
)
290+
291+
152292
class NoDuplicateDefinitionsTest(unittest.TestCase):
153293
"""Python shadows a repeated class or method silently; unittest counts the
154294
later one and the total still goes up, so a duplicated block looks like

0 commit comments

Comments
 (0)