From 6961b4d7e99153a9319ebc14b3d603ea938d3b04 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 22 Dec 2025 08:29:27 +0000 Subject: [PATCH] Add DNS-01 challenge support for Let's Encrypt Co-authored-by: mathieumoran1 --- python_video_server/README.md | 22 +++ .../__pycache__/letsencrypt.cpython-312.pyc | Bin 9565 -> 13403 bytes python_video_server/letsencrypt.py | 151 +++++++++++++++--- 3 files changed, 155 insertions(+), 18 deletions(-) diff --git a/python_video_server/README.md b/python_video_server/README.md index d7517f4..f8311a5 100644 --- a/python_video_server/README.md +++ b/python_video_server/README.md @@ -37,6 +37,28 @@ sudo -E python3 -m python_video_server.letsencrypt certonly \ --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 you@example.com \ + --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: - `python_video_server/certs/config/live//fullchain.pem` - `python_video_server/certs/config/live//privkey.pem` diff --git a/python_video_server/__pycache__/letsencrypt.cpython-312.pyc b/python_video_server/__pycache__/letsencrypt.cpython-312.pyc index 0c8d9354c982a315afd6ba7eb77c056c5d98cb4d..db3748896b5552326550706679529c0304775b6b 100644 GIT binary patch delta 5416 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 Le6Vg#jgI~a?6tU6 delta 2036 zcmZWqOKclO7~Wa0?e#Np{EFi`wiD;!G@HgvRnpSZS5)1Jx%EIbt$HaO+8e2akR^n;A#nheLrVn&7o-Xy%F!i-iok*YU&~2=iT3;d`Cl`h z|C`Cs}0*o*JK9oJze%;+lzP{dbYN3V7SEUIT^->#1H}z3F$R_Hi4v@_@ z;s1X$K%L<8&>(ezYys&8>7}i-38asPXd7+5YM$_`0qS|ZR}h#>6x1Mm{w3T3j3rW+ zwztW=DH)#IVybw^X0b9Z-4#iq^1|{JAp@1q<+w<8@?Y#XE8qA&CFX6AG{SEL%E8Hc zx*$lPIN>u10$Ca4yMlR#R5q6_)I=p}=KF%}{Atj&AT<`GR^8gjEt%J>@&>DA&1$3e zX=mA5GBIb#N*%h~$eb37E-L?MN`>jl@&#WCN0bd#hHST1gM%co$+jF zc(<);R%bHV9BV^g7y9h8tU%|WRw2*2(b|ImT@b2LhD~cM1gZv`6Zp@eZqEoP<7oK| zU{R>KdAMzdv}sxP^HCWeFlT)n^-#c}miy zC{NkFJ=R9Mhyby%jZt-dJ$6A}kJURHk)<_qKwoTQRe4fndLf^iFFK(MdL~aZxk6qo z1{7tMsZ%OrDpe*gW^y?-Kdow2>7uUBuDTTkr81aOW(!PDN^Bo2u-+H89~dfdcQi^` z_&_w(4Re9Lf-nG3bz*jUFqOt^hFviV@TF+%B=SCKW(UxSJ+4>90#zq&_Ci5VV{0`t zm|{4XyY8H!>Bg^NC&5&LPkSKn;n?WOnnkdAm*v0{IkqgjaTfYPc3eB~uJm1V{9$wa zk+b8G+*$KB`z+6Wf<3z261dfOy>HnUUiL?pgYoA!v)x|vgL`qbmJnnok9YYiH{&re zu^pCS!w5SNb|SotunS=~!eRb>XL#x;s>mtUgfNO=91PODemZ+l+lw%U5CEuJDVtBT zxjZvY#=7BTjO2gQo}J(miJ0XKB;FSIV&a*&Dps_v--t}@wEdoQ$;3bGiJGG@i}AmD z6cXpj-nglQcJgDro#q7CyZ9Vv5zxB%d%cP;zRsf(+@565STV^z5|b5c@(5`^hg&pF z&d}3Iu_{qj)2lL5-Q!Xnak;#(Q9^w|D>es z8kGA4KJ5yR47{iu+(HRa`O;uGg&ue=GE7qq`oOa?udx(bt<$QW(REgTb8rD5bc(+i z>~U;?2}5`)@MP+p4uisFn6KJKvzmSuo~H8f zo~oQq=V3?bG*ftPC=$8~9{jhbeE?815kj7cJ|g|z&+iNk9d(j{T6;HXe&TS~%pl=D xx`Q<Jet=Uks3m$*XftpjWG_ANmTRBA{q}lG6W8 tuple[Path, Path, Path]: 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: try: # certbot is a Python package dependency (installed via pip). @@ -86,6 +165,7 @@ def _common_certbot_flags( domains: List[str], base_dir: Path, production: bool, + non_interactive: bool = True, ) -> List[str]: if not email.strip(): raise SystemExit("--email is required") @@ -95,7 +175,6 @@ def _common_certbot_flags( config_dir, work_dir, logs_dir = _ensure_dirs(base_dir) args: List[str] = [ - "--non-interactive", "--agree-tos", "--email", email.strip(), @@ -109,6 +188,8 @@ def _common_certbot_flags( "--key-type", "ecdsa", ] + if non_interactive: + args.insert(0, "--non-interactive") if production: args += ["--server", LE_PRODUCTION_DIRECTORY_URL] @@ -126,23 +207,51 @@ def cmd_certonly(args: argparse.Namespace) -> int: domains = _split_domains(args.domains) production = bool(args.production) - certbot_args = [ - "certonly", - "--standalone", - # HTTP-01 is the simplest/most compatible for typical VPS setups. - "--preferred-challenges", - "http", - # If port 80 is already in use, certbot can still succeed behind a reverse proxy - # using `--webroot`, but this wrapper defaults to standalone by design. - "--http-01-port", - str(int(args.http_01_port)), - *_common_certbot_flags( - email=args.email, - domains=domains, - base_dir=base_dir, - production=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 = [ + "certonly", + "--standalone", + # HTTP-01 is the simplest/most compatible for typical VPS setups. + "--preferred-challenges", + "http", + # If port 80 is already in use, certbot can still succeed behind a reverse proxy + # using `--webroot`, but this wrapper defaults to standalone by design. + "--http-01-port", + str(int(args.http_01_port)), + *_common_certbot_flags( + email=args.email, + domains=domains, + base_dir=base_dir, + production=production, + non_interactive=True, + ), + ] if args.force_renewal: certbot_args.append("--force-renewal") @@ -212,6 +321,12 @@ def _build_parser() -> argparse.ArgumentParser: type=int, 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( "--test-cert",