Skip to content

Commit 19d4af0

Browse files
authored
Merge pull request #110 from Dstack-TEE/fix/ingress-renew-days-removable
fix(dstack-ingress): let RENEW_DAYS_BEFORE be unset again
2 parents 88c639e + d6dd438 commit 19d4af0

3 files changed

Lines changed: 233 additions & 12 deletions

File tree

‎custom-domain/dstack-ingress/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ environment:
181181
| `DOH_RESOLVERS` | Google + Cloudflare | Comma-separated DoH endpoints used to verify records |
182182
| `TLSALPN_PORT` | `9443` | Loopback port lego's ACME responder binds to |
183183
| `TLS_TERMINATE_PORT` | `9444` | Loopback port the TLS frontend moves to in tls-alpn-01 mode |
184-
| `RENEW_DAYS_BEFORE` | client default | Days of remaining lifetime that trigger renewal. Applies to both modes: passed to lego as `--renew-days`, and written to certbot's `renew_before_expiry` |
184+
| `RENEW_DAYS_BEFORE` | client default | Days of remaining lifetime that trigger renewal. Applies to both modes: passed to lego as `--renew-days`, and written to certbot's `renew_before_expiry`. Unsetting it removes that setting again, so the client's own default applies |
185185
| `RENEW_INTERVAL` | `43200` | Seconds between successful certificate passes |
186186
| `DNS_SETTLE_SECONDS` | `30` | Wait after DNS verifies, so the gateway's own TXT cache expires |
187187
| `MAXCONN` | `4096` | HAProxy max connections |

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

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,10 @@ def _lineage_name(domain: str) -> str:
482482
"""certbot stores a wildcard lineage under the bare name."""
483483
return domain[2:] if domain.startswith("*.") else domain
484484

485+
def _renewal_conf_path(self, domain: str) -> str:
486+
"""Where certbot keeps this lineage's renewal config."""
487+
return f"/etc/letsencrypt/renewal/{self._lineage_name(domain)}.conf"
488+
485489
def apply_renewal_window(self, domain: str) -> None:
486490
"""Make RENEW_DAYS_BEFORE mean the same thing here as it does for lego.
487491
@@ -491,56 +495,103 @@ def apply_renewal_window(self, domain: str) -> None:
491495
runs. Without this the variable is silently tls-alpn-01 only, which also
492496
left the dns-01 renewal branch with no way to be exercised on demand.
493497
494-
Leaving it unset keeps certbot's own default (30 days).
498+
Unsetting it has to remove the setting again, not merely stop writing
499+
it: the lineage config lives in the certificate volume and outlives the
500+
container, so a value written once would otherwise be permanent, and
501+
the documented way to leave the renewal window -- unset the variable --
502+
would do nothing while the certificate stayed permanently due.
503+
504+
This assumes the container owns the lineage config: clearing removes
505+
any active renew_before_expiry, including one a human put there, since
506+
nothing distinguishes the two. That holds for the volume this image
507+
manages. Mounting a certbot directory maintained elsewhere is out of
508+
scope -- set RENEW_DAYS_BEFORE to the value you want in that case,
509+
rather than leaving it unset and expecting the file to be left alone.
495510
"""
496511
days = os.environ.get("RENEW_DAYS_BEFORE", "").strip()
497-
if not days:
498-
return
499-
if not days.isdigit() or int(days) < 1:
512+
if days and (not days.isdigit() or int(days) < 1):
500513
print(
501514
f"Warning: ignoring invalid RENEW_DAYS_BEFORE={days!r} "
502515
f"(expected a positive number of days)",
503516
file=sys.stderr,
504517
)
505518
return
506519

507-
path = f"/etc/letsencrypt/renewal/{self._lineage_name(domain)}.conf"
520+
path = self._renewal_conf_path(domain)
508521
if not os.path.isfile(path):
509522
# No lineage yet: the first issuance has not happened, and certbot
510523
# writes this file itself. Nothing to do.
511524
return
512525

513-
setting = f"renew_before_expiry = {days} days"
526+
setting = f"renew_before_expiry = {days} days" if days else None
514527
try:
515528
with open(path, encoding="utf-8") as fh:
516529
lines = fh.read().splitlines()
517530
except OSError as exc:
518531
print(f"Warning: cannot read {path}: {exc}", file=sys.stderr)
519532
return
520533

521-
out, replaced = [], False
534+
out, replaced, removed = [], False, False
522535
for line in lines:
523-
# The key ships commented out, so match both forms.
524-
if re.match(r"\s*#?\s*renew_before_expiry\s*=", line):
536+
# The key ships commented out, so match both forms when writing.
537+
# When clearing, drop only the active setting: the commented
538+
# template is certbot's own and carries no value.
539+
active = re.match(r"\s*renew_before_expiry\s*=", line)
540+
if setting is not None and re.match(
541+
r"\s*#?\s*renew_before_expiry\s*=", line
542+
):
525543
if not replaced:
526544
out.append(setting)
527545
replaced = True
528546
continue
547+
if setting is None and active:
548+
removed = True
549+
continue
529550
out.append(line)
530551

531-
if not replaced:
552+
if setting is not None and not replaced:
532553
# Must land before the first section header; the key is top-level.
533554
insert_at = next(
534555
(i for i, line in enumerate(out) if line.strip().startswith("[")),
535556
len(out),
536557
)
537558
out.insert(insert_at, setting)
559+
replaced = True
538560

561+
if out == lines:
562+
# Nothing to do: already correct, or already absent. Rewriting the
563+
# file every pass would only add noise and a chance to corrupt it.
564+
return
565+
566+
# Write through a temporary file and rename over the original. Opening
567+
# the live config with "w" truncates it before anything is written, so
568+
# a crash or a full disk mid-write would leave certbot with a truncated
569+
# lineage config -- and a lineage certbot cannot parse is one it cannot
570+
# renew. os.replace is atomic, so a reader sees the old file or the new
571+
# one, never a half-written one.
572+
tmp = f"{path}.dstack-tmp"
539573
try:
540-
with open(path, "w", encoding="utf-8") as fh:
574+
with open(tmp, "w", encoding="utf-8") as fh:
541575
fh.write("\n".join(out) + "\n")
576+
fh.flush()
577+
os.fsync(fh.fileno())
578+
os.replace(tmp, path)
542579
except OSError as exc:
543580
print(f"Warning: cannot write {path}: {exc}", file=sys.stderr)
581+
try:
582+
os.unlink(tmp)
583+
except OSError as cleanup_exc:
584+
print(
585+
f"Warning: cleanup failed for temporary file {tmp}: {cleanup_exc}",
586+
file=sys.stderr,
587+
)
588+
return
589+
590+
if removed:
591+
print(
592+
f"Renewal window for {domain} cleared; "
593+
f"certbot's default applies again"
594+
)
544595
return
545596
print(f"Renewal window for {domain} set to {days} days before expiry")
546597

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
#!/usr/bin/env python3
2+
"""Unit tests for certman's renewal-window handling.
3+
4+
Run: python3 scripts/tests/test_certman.py
5+
"""
6+
7+
import os
8+
import shutil
9+
import sys
10+
import tempfile
11+
import unittest
12+
13+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
14+
15+
import certman # noqa: E402
16+
17+
# A lineage config as certbot writes it: the key ships commented out, and
18+
# everything after the first section header is out of bounds for a top-level key.
19+
CERTBOT_CONF = """\
20+
version = 5.7.0
21+
archive_dir = /etc/letsencrypt/archive/example.com
22+
cert = /etc/letsencrypt/live/example.com/cert.pem
23+
# renew_before_expiry = 30 days
24+
25+
[renewalparams]
26+
authenticator = dns-cloudflare
27+
"""
28+
29+
30+
def _manager(path: str) -> certman.CertManager:
31+
"""A CertManager that reads and writes one temp file.
32+
33+
__init__ builds a provider from the environment; none of that is involved
34+
in rewriting a config file, so bypass it.
35+
"""
36+
mgr = object.__new__(certman.CertManager)
37+
mgr._renewal_conf_path = lambda domain: path # noqa: SLF001
38+
return mgr
39+
40+
41+
class RenewalWindowTest(unittest.TestCase):
42+
def setUp(self):
43+
self._saved = os.environ.get("RENEW_DAYS_BEFORE")
44+
# Its own directory, so a test may make the directory unwritable
45+
# without touching anything it does not own.
46+
self.dir = tempfile.mkdtemp()
47+
self.path = os.path.join(self.dir, "example.com.conf")
48+
self.write(CERTBOT_CONF)
49+
50+
def tearDown(self):
51+
os.chmod(self.dir, 0o700)
52+
shutil.rmtree(self.dir, ignore_errors=True)
53+
if self._saved is None:
54+
os.environ.pop("RENEW_DAYS_BEFORE", None)
55+
else:
56+
os.environ["RENEW_DAYS_BEFORE"] = self._saved
57+
58+
def write(self, text):
59+
with open(self.path, "w", encoding="utf-8") as fh:
60+
fh.write(text)
61+
62+
def read(self):
63+
with open(self.path, encoding="utf-8") as fh:
64+
return fh.read()
65+
66+
def apply(self, value):
67+
if value is None:
68+
os.environ.pop("RENEW_DAYS_BEFORE", None)
69+
else:
70+
os.environ["RENEW_DAYS_BEFORE"] = value
71+
_manager(self.path).apply_renewal_window("example.com")
72+
73+
def test_setting_is_written_above_the_first_section(self):
74+
self.apply("365")
75+
body = self.read()
76+
self.assertIn("renew_before_expiry = 365 days", body)
77+
self.assertLess(
78+
body.index("renew_before_expiry"), body.index("[renewalparams]")
79+
)
80+
81+
def test_setting_replaces_a_previous_value_without_duplicating(self):
82+
self.apply("365")
83+
self.apply("45")
84+
body = self.read()
85+
self.assertIn("renew_before_expiry = 45 days", body)
86+
self.assertNotIn("365", body)
87+
self.assertEqual(body.count("renew_before_expiry"), 1)
88+
89+
def test_unsetting_removes_a_previously_written_value(self):
90+
"""The regression: the lineage config outlives the container.
91+
92+
Leaving the setting behind kept the certificate permanently due, and
93+
the documented way out -- unset the variable -- did nothing.
94+
"""
95+
self.apply("365")
96+
self.assertIn("renew_before_expiry = 365 days", self.read())
97+
self.apply(None)
98+
self.assertNotIn("renew_before_expiry = 365 days", self.read())
99+
100+
def test_unsetting_leaves_certbots_commented_template_alone(self):
101+
self.apply(None)
102+
self.assertIn("# renew_before_expiry = 30 days", self.read())
103+
104+
def test_unsetting_on_an_untouched_config_changes_nothing(self):
105+
self.apply(None)
106+
self.assertEqual(self.read(), CERTBOT_CONF)
107+
108+
def test_invalid_value_leaves_the_config_alone(self):
109+
self.apply("365")
110+
before = self.read()
111+
for bad in ("0", "-1", "later", "30 days"):
112+
self.apply(bad)
113+
self.assertEqual(self.read(), before, f"{bad!r} should be ignored")
114+
115+
def test_missing_lineage_is_not_an_error(self):
116+
os.unlink(self.path)
117+
self.apply("365")
118+
self.assertFalse(os.path.exists(self.path))
119+
120+
def test_write_is_atomic_and_leaves_no_scratch_file(self):
121+
"""The live config is renamed into place, never truncated in place.
122+
123+
Opening it with "w" would empty it before the replacement was written,
124+
and a lineage config certbot cannot parse is one it cannot renew.
125+
"""
126+
self.apply("365")
127+
self.assertFalse(
128+
os.path.exists(self.path + ".dstack-tmp"),
129+
"temporary file left behind",
130+
)
131+
self.assertTrue(self.read().endswith("\n"))
132+
self.assertIn("[renewalparams]", self.read())
133+
134+
def test_a_failed_write_leaves_the_original_intact(self):
135+
original = self.read()
136+
os.chmod(self.dir, 0o500) # cannot create the temp file
137+
try:
138+
self.apply("365")
139+
finally:
140+
os.chmod(self.dir, 0o700)
141+
self.assertEqual(self.read(), original)
142+
143+
def test_wildcard_uses_the_bare_lineage_name(self):
144+
self.assertEqual(
145+
certman.CertManager._lineage_name("*.example.com"), "example.com"
146+
)
147+
self.assertEqual(
148+
certman.CertManager._lineage_name("example.com"), "example.com"
149+
)
150+
151+
152+
class NoDuplicateDefinitionsTest(unittest.TestCase):
153+
"""Python shadows a repeated class or method silently; unittest counts the
154+
later one and the total still goes up, so a duplicated block looks like
155+
passing tests. Same guard as test_dnsguide.py."""
156+
157+
def test_no_duplicate_definitions(self):
158+
import ast, collections, pathlib
159+
160+
tree = ast.parse(pathlib.Path(__file__).read_text())
161+
names = [n.name for n in tree.body if isinstance(n, ast.ClassDef)]
162+
for cls in (n for n in tree.body if isinstance(n, ast.ClassDef)):
163+
names += [f"{cls.name}.{m.name}" for m in cls.body
164+
if isinstance(m, (ast.FunctionDef, ast.AsyncFunctionDef))]
165+
dupes = [n for n, c in collections.Counter(names).items() if c > 1]
166+
self.assertEqual(dupes, [], f"defined more than once: {dupes}")
167+
168+
169+
if __name__ == "__main__":
170+
unittest.main(verbosity=2)

0 commit comments

Comments
 (0)