Add DNS-01 challenge support for Let's Encrypt

Co-authored-by: mathieumoran1 <[email protected]>
This commit is contained in:
Cursor Agent
2025-12-22 08:29:27 +00:00
co-authored by mathieumoran1
parent 812590d262
commit 6961b4d7e9
3 changed files with 155 additions and 18 deletions
+22
View File
@@ -37,6 +37,28 @@ sudo -E python3 -m python_video_server.letsencrypt certonly \
--domains example.com,www.example.com --domains example.com,www.example.com
``` ```
### 1b) Issue a certificate (DNS-01 manual TXT record — no port 80, no root)
If your host already uses port **80** (or you don't have permission to bind it), use DNS-01.
This does **not** require any inbound ports on the machine, but you must be able to edit DNS for the domain.
```bash
python3 -m pip install -r python_video_server/requirements.txt
# Add --production when staging works.
python3 -m python_video_server.letsencrypt certonly \
--challenge dns \
--email [email protected] \
--domains example.com,www.example.com
```
During issuance, you'll be prompted to create a TXT record like:
- Name: `_acme-challenge.example.com`
- Type: `TXT`
- Value: (a token certbot prints)
After it propagates, press Enter and certbot will continue.
Certificates will be written under: Certificates will be written under:
- `python_video_server/certs/config/live/<primary-domain>/fullchain.pem` - `python_video_server/certs/config/live/<primary-domain>/fullchain.pem`
- `python_video_server/certs/config/live/<primary-domain>/privkey.pem` - `python_video_server/certs/config/live/<primary-domain>/privkey.pem`
+116 -1
View File
@@ -14,6 +14,14 @@ Typical usage (standalone HTTP-01, binds to port 80):
--domains example.com,www.example.com \ --domains example.com,www.example.com \
--production --production
If you *cannot* bind port 80 (shared hosting, no root, port already in use),
use DNS-01 instead (manual TXT record):
python3 -m python_video_server.letsencrypt certonly \
--challenge dns \
--email [email protected] \
--domains example.com,www.example.com \
--production
Then run uvicorn with: Then run uvicorn with:
sudo -E uvicorn python_video_server.server:app --host 0.0.0.0 --port 443 \ sudo -E uvicorn python_video_server.server:app --host 0.0.0.0 --port 443 \
--ssl-certfile python_video_server/certs/config/live/example.com/fullchain.pem \ --ssl-certfile python_video_server/certs/config/live/example.com/fullchain.pem \
@@ -65,6 +73,77 @@ def _ensure_dirs(base_dir: Path) -> tuple[Path, Path, Path]:
return config_dir, work_dir, logs_dir return config_dir, work_dir, logs_dir
def _ensure_dns_hook_scripts(work_dir: Path) -> tuple[Path, Path]:
"""
Create small executable hook scripts used by certbot's --manual DNS flow.
Certbot runs these hooks as external executables; we generate them under the
certbot work dir so no special permissions are needed.
"""
auth_hook = (work_dir / "certbot_dns_auth_hook.py").resolve()
cleanup_hook = (work_dir / "certbot_dns_cleanup_hook.py").resolve()
auth_contents = """#!/usr/bin/env python3
import os
import sys
import time
def main() -> int:
domain = os.environ.get("CERTBOT_DOMAIN", "").strip()
validation = os.environ.get("CERTBOT_VALIDATION", "").strip()
if not domain or not validation:
print("Missing CERTBOT_DOMAIN/CERTBOT_VALIDATION in environment.", file=sys.stderr)
return 2
record_name = f"_acme-challenge.{domain}"
print("\\n=== DNS-01 challenge required ===")
print(f"Create/Update this TXT record:")
print(f" Name: {record_name}")
print(f" Type: TXT")
print(f" Value: {validation}")
print("\\nAfter the record is published and has propagated, press Enter to continue.")
# If we're in a non-interactive environment, wait a bit and then continue.
if not sys.stdin.isatty():
wait_s = int(os.environ.get("LE_DNS_WAIT_SECONDS", "60"))
print(f"(non-interactive stdin detected; waiting {wait_s}s then continuing...)")
time.sleep(max(0, wait_s))
return 0
try:
input("> ")
except KeyboardInterrupt:
print("\\nAborted.", file=sys.stderr)
return 130
return 0
if __name__ == "__main__":
raise SystemExit(main())
"""
cleanup_contents = """#!/usr/bin/env python3
import os
def main() -> int:
domain = os.environ.get("CERTBOT_DOMAIN", "").strip()
if domain:
print("\\nDNS-01 cleanup:")
print(f"You may now remove the TXT record: _acme-challenge.{domain}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
"""
auth_hook.write_text(auth_contents, encoding="utf-8")
cleanup_hook.write_text(cleanup_contents, encoding="utf-8")
os.chmod(auth_hook, 0o755)
os.chmod(cleanup_hook, 0o755)
return auth_hook, cleanup_hook
def _run_certbot(argv: Iterable[str]) -> int: def _run_certbot(argv: Iterable[str]) -> int:
try: try:
# certbot is a Python package dependency (installed via pip). # certbot is a Python package dependency (installed via pip).
@@ -86,6 +165,7 @@ def _common_certbot_flags(
domains: List[str], domains: List[str],
base_dir: Path, base_dir: Path,
production: bool, production: bool,
non_interactive: bool = True,
) -> List[str]: ) -> List[str]:
if not email.strip(): if not email.strip():
raise SystemExit("--email is required") raise SystemExit("--email is required")
@@ -95,7 +175,6 @@ def _common_certbot_flags(
config_dir, work_dir, logs_dir = _ensure_dirs(base_dir) config_dir, work_dir, logs_dir = _ensure_dirs(base_dir)
args: List[str] = [ args: List[str] = [
"--non-interactive",
"--agree-tos", "--agree-tos",
"--email", "--email",
email.strip(), email.strip(),
@@ -109,6 +188,8 @@ def _common_certbot_flags(
"--key-type", "--key-type",
"ecdsa", "ecdsa",
] ]
if non_interactive:
args.insert(0, "--non-interactive")
if production: if production:
args += ["--server", LE_PRODUCTION_DIRECTORY_URL] args += ["--server", LE_PRODUCTION_DIRECTORY_URL]
@@ -126,6 +207,33 @@ def cmd_certonly(args: argparse.Namespace) -> int:
domains = _split_domains(args.domains) domains = _split_domains(args.domains)
production = bool(args.production) production = bool(args.production)
challenge = (args.challenge or "http").strip().lower()
if challenge not in {"http", "dns"}:
raise SystemExit("--challenge must be one of: http, dns")
if challenge == "dns":
# DNS-01 works without binding any local ports; suitable for shared hosting / no root.
_, work_dir, _ = _ensure_dirs(base_dir)
auth_hook, cleanup_hook = _ensure_dns_hook_scripts(work_dir)
certbot_args = [
"certonly",
"--manual",
"--preferred-challenges",
"dns",
"--manual-public-ip-logging-ok",
"--manual-auth-hook",
str(auth_hook),
"--manual-cleanup-hook",
str(cleanup_hook),
*_common_certbot_flags(
email=args.email,
domains=domains,
base_dir=base_dir,
production=production,
non_interactive=True,
),
]
else:
certbot_args = [ certbot_args = [
"certonly", "certonly",
"--standalone", "--standalone",
@@ -141,6 +249,7 @@ def cmd_certonly(args: argparse.Namespace) -> int:
domains=domains, domains=domains,
base_dir=base_dir, base_dir=base_dir,
production=production, production=production,
non_interactive=True,
), ),
] ]
@@ -212,6 +321,12 @@ def _build_parser() -> argparse.ArgumentParser:
type=int, type=int,
help="Port to bind for HTTP-01 challenge (default: 80).", help="Port to bind for HTTP-01 challenge (default: 80).",
) )
c.add_argument(
"--challenge",
default=os.environ.get("LE_CHALLENGE", "http"),
choices=["http", "dns"],
help="ACME challenge type: http (standalone) or dns (manual TXT record). Default: http.",
)
c.add_argument("--force-renewal", action="store_true", help="Force issuance even if not close to expiry.") c.add_argument("--force-renewal", action="store_true", help="Force issuance even if not close to expiry.")
c.add_argument( c.add_argument(
"--test-cert", "--test-cert",