Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion custom-domain/dstack-ingress/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ environment:
| `DOH_RESOLVERS` | Google + Cloudflare | Comma-separated DoH endpoints used to verify records |
| `TLSALPN_PORT` | `9443` | Loopback port lego's ACME responder binds to |
| `TLS_TERMINATE_PORT` | `9444` | Loopback port the TLS frontend moves to in tls-alpn-01 mode |
| `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` |
| `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 |
| `RENEW_INTERVAL` | `43200` | Seconds between successful certificate passes |
| `DNS_SETTLE_SECONDS` | `30` | Wait after DNS verifies, so the gateway's own TXT cache expires |
| `MAXCONN` | `4096` | HAProxy max connections |
Expand Down
47 changes: 37 additions & 10 deletions custom-domain/dstack-ingress/scripts/certman.py
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,10 @@
"""certbot stores a wildcard lineage under the bare name."""
return domain[2:] if domain.startswith("*.") else domain

def _renewal_conf_path(self, domain: str) -> str:
"""Where certbot keeps this lineage's renewal config."""
return f"/etc/letsencrypt/renewal/{self._lineage_name(domain)}.conf"

def apply_renewal_window(self, domain: str) -> None:
"""Make RENEW_DAYS_BEFORE mean the same thing here as it does for lego.

Expand All @@ -491,57 +495,80 @@
runs. Without this the variable is silently tls-alpn-01 only, which also
left the dns-01 renewal branch with no way to be exercised on demand.

Leaving it unset keeps certbot's own default (30 days).
Unsetting it has to remove the setting again, not merely stop writing
it: the lineage config lives in the certificate volume and outlives the
container, so a value written once would otherwise be permanent, and
the documented way to leave the renewal window -- unset the variable --
would do nothing while the certificate stayed permanently due.
"""
days = os.environ.get("RENEW_DAYS_BEFORE", "").strip()
if not days:
return
if not days.isdigit() or int(days) < 1:
if days and (not days.isdigit() or int(days) < 1):
print(
f"Warning: ignoring invalid RENEW_DAYS_BEFORE={days!r} "
f"(expected a positive number of days)",
file=sys.stderr,
)
return

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

setting = f"renew_before_expiry = {days} days"
setting = f"renew_before_expiry = {days} days" if days else None
try:
with open(path, encoding="utf-8") as fh:
lines = fh.read().splitlines()
except OSError as exc:
print(f"Warning: cannot read {path}: {exc}", file=sys.stderr)
return

out, replaced = [], False
out, replaced, removed = [], False, False
for line in lines:
# The key ships commented out, so match both forms.
if re.match(r"\s*#?\s*renew_before_expiry\s*=", line):
# The key ships commented out, so match both forms when writing.
# When clearing, drop only the active setting: the commented
# template is certbot's own and carries no value.
active = re.match(r"\s*renew_before_expiry\s*=", line)
if setting is not None and re.match(
r"\s*#?\s*renew_before_expiry\s*=", line
):
if not replaced:
out.append(setting)
replaced = True
continue
if setting is None and active:
removed = True
continue
out.append(line)

if not replaced:
if setting is not None and not replaced:
# Must land before the first section header; the key is top-level.
insert_at = next(
(i for i, line in enumerate(out) if line.strip().startswith("[")),
len(out),
)
out.insert(insert_at, setting)
replaced = True

if out == lines:
# Nothing to do: already correct, or already absent. Rewriting the
# file every pass would only add noise and a chance to corrupt it.
return

try:
with open(path, "w", encoding="utf-8") as fh:
fh.write("\n".join(out) + "\n")
except OSError as exc:
print(f"Warning: cannot write {path}: {exc}", file=sys.stderr)
return

if removed:
print(
f"Renewal window for {domain} cleared; "
f"certbot's default applies again"
)
return
print(f"Renewal window for {domain} set to {days} days before expiry")

def acme_account_exists(self) -> bool:
Expand Down
144 changes: 144 additions & 0 deletions custom-domain/dstack-ingress/scripts/tests/test_certman.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Unit tests for certman's renewal-window handling.

Run: python3 scripts/tests/test_certman.py
"""

import os
import sys
import tempfile
import unittest

sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))

import certman # noqa: E402

# A lineage config as certbot writes it: the key ships commented out, and
# everything after the first section header is out of bounds for a top-level key.
CERTBOT_CONF = """\
version = 5.7.0
archive_dir = /etc/letsencrypt/archive/example.com
cert = /etc/letsencrypt/live/example.com/cert.pem
# renew_before_expiry = 30 days

[renewalparams]
authenticator = dns-cloudflare
"""


def _manager(path: str) -> certman.CertManager:
"""A CertManager that reads and writes one temp file.

__init__ builds a provider from the environment; none of that is involved
in rewriting a config file, so bypass it.
"""
mgr = object.__new__(certman.CertManager)
mgr._renewal_conf_path = lambda domain: path # noqa: SLF001
return mgr


class RenewalWindowTest(unittest.TestCase):
def setUp(self):
self._saved = os.environ.get("RENEW_DAYS_BEFORE")
fd, self.path = tempfile.mkstemp(suffix=".conf")
os.close(fd)
self.write(CERTBOT_CONF)

def tearDown(self):
os.unlink(self.path)
if self._saved is None:
os.environ.pop("RENEW_DAYS_BEFORE", None)
else:
os.environ["RENEW_DAYS_BEFORE"] = self._saved

def write(self, text):
with open(self.path, "w", encoding="utf-8") as fh:
fh.write(text)

def read(self):
with open(self.path, encoding="utf-8") as fh:
return fh.read()

def apply(self, value):
if value is None:
os.environ.pop("RENEW_DAYS_BEFORE", None)
else:
os.environ["RENEW_DAYS_BEFORE"] = value
_manager(self.path).apply_renewal_window("example.com")

def test_setting_is_written_above_the_first_section(self):
self.apply("365")
body = self.read()
self.assertIn("renew_before_expiry = 365 days", body)
self.assertLess(
body.index("renew_before_expiry"), body.index("[renewalparams]")
)

def test_setting_replaces_a_previous_value_without_duplicating(self):
self.apply("365")
self.apply("45")
body = self.read()
self.assertIn("renew_before_expiry = 45 days", body)
self.assertNotIn("365", body)
self.assertEqual(body.count("renew_before_expiry"), 1)

def test_unsetting_removes_a_previously_written_value(self):
"""The regression: the lineage config outlives the container.

Leaving the setting behind kept the certificate permanently due, and
the documented way out -- unset the variable -- did nothing.
"""
self.apply("365")
self.assertIn("renew_before_expiry = 365 days", self.read())
self.apply(None)
self.assertNotIn("renew_before_expiry = 365 days", self.read())

def test_unsetting_leaves_certbots_commented_template_alone(self):
self.apply(None)
self.assertIn("# renew_before_expiry = 30 days", self.read())

def test_unsetting_on_an_untouched_config_changes_nothing(self):
self.apply(None)
self.assertEqual(self.read(), CERTBOT_CONF)

def test_invalid_value_leaves_the_config_alone(self):
self.apply("365")
before = self.read()
for bad in ("0", "-1", "later", "30 days"):
self.apply(bad)
self.assertEqual(self.read(), before, f"{bad!r} should be ignored")

def test_missing_lineage_is_not_an_error(self):
os.unlink(self.path)
self.apply("365")
self.assertFalse(os.path.exists(self.path))
open(self.path, "w").close() # tearDown unlinks it

def test_wildcard_uses_the_bare_lineage_name(self):
self.assertEqual(
certman.CertManager._lineage_name("*.example.com"), "example.com"
)
self.assertEqual(
certman.CertManager._lineage_name("example.com"), "example.com"
)


class NoDuplicateDefinitionsTest(unittest.TestCase):
"""Python shadows a repeated class or method silently; unittest counts the
later one and the total still goes up, so a duplicated block looks like
passing tests. Same guard as test_dnsguide.py."""

def test_no_duplicate_definitions(self):
import ast, collections, pathlib

tree = ast.parse(pathlib.Path(__file__).read_text())
names = [n.name for n in tree.body if isinstance(n, ast.ClassDef)]
for cls in (n for n in tree.body if isinstance(n, ast.ClassDef)):
names += [f"{cls.name}.{m.name}" for m in cls.body
if isinstance(m, (ast.FunctionDef, ast.AsyncFunctionDef))]
dupes = [n for n, c in collections.Counter(names).items() if c > 1]
self.assertEqual(dupes, [], f"defined more than once: {dupes}")


if __name__ == "__main__":
unittest.main(verbosity=2)
Loading