From 5325576dc47d8aed895ae6f8c9d628b1e5002f2b Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Mon, 22 Dec 2025 04:16:45 -0500 Subject: [PATCH] Dns validation for https (#49) * Add DNS-01 challenge support for Let's Encrypt Co-authored-by: mathieumoran1 * Refactor letsencrypt script to use /dev/tty for interactive prompts Co-authored-by: mathieumoran1 * feat: Add optional DNS hook mode for certonly Co-authored-by: mathieumoran1 * Refactor: Improve MongoDB connection and add certifi Co-authored-by: mathieumoran1 * Checkpoint before follow-up message Co-authored-by: mathieumoran1 --------- Co-authored-by: Cursor Agent --- python_video_server/README.md | 32 +++ .../__pycache__/letsencrypt.cpython-312.pyc | Bin 9565 -> 15062 bytes .../__pycache__/server.cpython-312.pyc | Bin 12923 -> 15555 bytes python_video_server/letsencrypt.py | 217 ++++++++++++++++-- python_video_server/requirements.txt | 1 + python_video_server/server.py | 91 +++++++- 6 files changed, 315 insertions(+), 26 deletions(-) diff --git a/python_video_server/README.md b/python_video_server/README.md index d7517f4..54d0eba 100644 --- a/python_video_server/README.md +++ b/python_video_server/README.md @@ -37,6 +37,38 @@ 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, certbot will prompt you 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. + +If you run into a situation where the prompt/token only shows up in logs, you can try the hook-based mode: + +```bash +python3 -m python_video_server.letsencrypt certonly \ + --challenge dns \ + --dns-hooks \ + --email you@example.com \ + --domains example.com,www.example.com +``` + 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..709b682ea4f5f9fddd475c29cdeed57670a93398 100644 GIT binary patch delta 7005 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 delta 1982 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 UjAND^ywg+taHsVjEmcteA6nERRsaA1 delta 4339 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 Yj}B=hexG9DoxDyO5A|uUrxO7G2X7tFOaK4? diff --git a/python_video_server/letsencrypt.py b/python_video_server/letsencrypt.py index c1abc7a..92d0489 100644 --- a/python_video_server/letsencrypt.py +++ b/python_video_server/letsencrypt.py @@ -14,6 +14,14 @@ Typical usage (standalone HTTP-01, binds to port 80): --domains example.com,www.example.com \ --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 you@example.com \ + --domains example.com,www.example.com \ + --production + Then run uvicorn with: 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 \ @@ -65,6 +73,121 @@ def _ensure_dirs(base_dir: Path) -> 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 _open_tty(): + # Certbot may capture stdout/stderr of hooks; /dev/tty forces user-visible IO + # when an interactive terminal exists. + try: + return open("/dev/tty", "r+", encoding="utf-8", buffering=1) + except Exception: + return None + +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, flush=True) + return 2 + + record_name = f"_acme-challenge.{domain}" + msg = ( + "\\n=== DNS-01 challenge required ===\\n" + "Create/Update this TXT record:\\n" + f" Name: {record_name}\\n" + " Type: TXT\\n" + f" Value: {validation}\\n" + "\\nAfter the record is published and has propagated, press Enter to continue.\\n" + "> " + ) + + tty = _open_tty() + if tty is not None: + tty.write(msg) + tty.flush() + try: + tty.readline() + except KeyboardInterrupt: + tty.write("\\nAborted.\\n") + tty.flush() + return 130 + finally: + try: + tty.close() + except Exception: + pass + return 0 + + # Fallback: best-effort stdout + either wait or read stdin if interactive. + print(msg, end="", flush=True) + if not sys.stdin.isatty(): + wait_s = int(os.environ.get("LE_DNS_WAIT_SECONDS", "300")) + print(f"\\n(non-interactive stdin detected; waiting {wait_s}s then continuing...)", flush=True) + time.sleep(max(0, wait_s)) + return 0 + try: + input("") + except KeyboardInterrupt: + print("\\nAborted.", file=sys.stderr, flush=True) + return 130 + return 0 + +if __name__ == "__main__": + raise SystemExit(main()) +""" + + cleanup_contents = """#!/usr/bin/env python3 +import os + +def _open_tty(): + try: + return open("/dev/tty", "w", encoding="utf-8", buffering=1) + except Exception: + return None + +def main() -> int: + domain = os.environ.get("CERTBOT_DOMAIN", "").strip() + if not domain: + return 0 + msg = "\\nDNS-01 cleanup:\\nYou may now remove the TXT record: _acme-challenge.%s\\n" % domain + tty = _open_tty() + if tty is not None: + tty.write(msg) + tty.flush() + try: + tty.close() + except Exception: + pass + return 0 + print(msg, end="", flush=True) + 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 +209,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 +219,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 +232,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 +251,67 @@ 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. + # + # Default: use certbot's built-in interactive manual prompt (most reliable). + # Optional: --dns-hooks to use our hook scripts (useful for automation). + certbot_args = [ + "certonly", + "--manual", + "--preferred-challenges", + "dns", + "--manual-public-ip-logging-ok", + ] + + if bool(args.dns_hooks): + _, work_dir, _ = _ensure_dirs(base_dir) + auth_hook, cleanup_hook = _ensure_dns_hook_scripts(work_dir) + certbot_args += [ + "--manual-auth-hook", + str(auth_hook), + "--manual-cleanup-hook", + str(cleanup_hook), + ] + certbot_args += _common_certbot_flags( + email=args.email, + domains=domains, + base_dir=base_dir, + production=production, + non_interactive=True, + ) + else: + # IMPORTANT: manual DNS prompt requires interactive mode. + certbot_args += _common_certbot_flags( + email=args.email, + domains=domains, + base_dir=base_dir, + production=production, + non_interactive=False, + ) + 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 +381,18 @@ 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( + "--dns-hooks", + action="store_true", + default=bool(int(os.environ.get("LE_DNS_HOOKS", "0"))), + help="(DNS challenge only) Use hook scripts to print token and wait. Default uses certbot's interactive manual prompt.", + ) c.add_argument("--force-renewal", action="store_true", help="Force issuance even if not close to expiry.") c.add_argument( "--test-cert", diff --git a/python_video_server/requirements.txt b/python_video_server/requirements.txt index 863ae3a..7f27c17 100644 --- a/python_video_server/requirements.txt +++ b/python_video_server/requirements.txt @@ -3,3 +3,4 @@ uvicorn[standard]==0.34.0 pymongo==4.10.1 PyJWT==2.10.1 certbot>=2.11.0,<3 +certifi==2025.11.12 diff --git a/python_video_server/server.py b/python_video_server/server.py index 70fb957..fee364f 100644 --- a/python_video_server/server.py +++ b/python_video_server/server.py @@ -1,6 +1,8 @@ import mimetypes import os import posixpath +import ssl +from functools import lru_cache from datetime import datetime, timezone from pathlib import Path from typing import Generator, Optional, Tuple @@ -13,6 +15,7 @@ from fastapi import FastAPI, Header, HTTPException, Request, Response from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, StreamingResponse from pymongo import MongoClient +from pymongo.errors import PyMongoError def _env_str(name: str, default: str = "") -> str: @@ -49,6 +52,12 @@ def _mongo_db_name() -> str: except Exception: return "" +def _env_int(name: str, default: int) -> int: + try: + return int(_env_str(name, str(default))) + except Exception: + return default + def _video_files_dir() -> Path: # Read env at runtime (important on Windows/uvicorn worker reloads). @@ -143,7 +152,51 @@ def _mongo_client() -> MongoClient: uri = _mongo_uri() if not uri: raise HTTPException(status_code=500, detail="MONGO_URI is not configured") - return MongoClient(uri) + kwargs = { + # Fail fast instead of hanging for ~30s on each request. + "serverSelectionTimeoutMS": _env_int("MONGO_SERVER_SELECTION_TIMEOUT_MS", 8000), + "connectTimeoutMS": _env_int("MONGO_CONNECT_TIMEOUT_MS", 8000), + "socketTimeoutMS": _env_int("MONGO_SOCKET_TIMEOUT_MS", 20000), + } + + # On some hosts, the system CA store is missing/broken or overridden by env vars, + # causing TLS handshake failures to MongoDB Atlas. Prefer certifi's CA bundle. + ca_file = _env_str("MONGO_TLS_CA_FILE") + if ca_file: + kwargs["tlsCAFile"] = ca_file + else: + try: + import certifi # type: ignore + + kwargs["tlsCAFile"] = certifi.where() + except Exception: + # If certifi isn't available, fall back to system trust. + pass + + # Some hosts / middleboxes break TLS 1.3 handshakes to Atlas and produce + # `TLSV1_ALERT_INTERNAL_ERROR`. Forcing TLS 1.2 can fix it. + if _env_str("MONGO_TLS_FORCE_TLS12", "0") in ("1", "true", "yes", "on"): + cafile = kwargs.pop("tlsCAFile", None) + ctx = ssl.create_default_context(cafile=cafile) + try: + ctx.minimum_version = ssl.TLSVersion.TLSv1_2 + ctx.maximum_version = ssl.TLSVersion.TLSv1_2 + except Exception: + # Older Python/OpenSSL may not support TLSVersion fields. + pass + kwargs["ssl_context"] = ctx + + # Optional escape hatch (NOT recommended): allow invalid certs for debugging only. + if _env_str("MONGO_TLS_INSECURE", "0") in ("1", "true", "yes", "on"): + kwargs["tlsAllowInvalidCertificates"] = True + + return MongoClient(uri, **kwargs) + + +@lru_cache(maxsize=1) +def _mongo_client_cached() -> MongoClient: + # Reuse one client (connection pool) across requests. + return _mongo_client() app = FastAPI() @@ -211,15 +264,26 @@ def stream_video( except Exception: raise HTTPException(status_code=400, detail="Invalid movie id") - with _mongo_client() as client: - db_name = _mongo_db_name() - if not db_name: - raise HTTPException( - status_code=500, - detail="Mongo DB name not configured. Add it to MONGO_URI (…/dbname) or set MONGO_DB_NAME.", - ) + db_name = _mongo_db_name() + if not db_name: + raise HTTPException( + status_code=500, + detail="Mongo DB name not configured. Add it to MONGO_URI (…/dbname) or set MONGO_DB_NAME.", + ) + + try: + client = _mongo_client_cached() db = client[db_name] movie = db["movies"].find_one({"_id": oid}, {"video_file": 1}) + except PyMongoError as e: + raise HTTPException( + status_code=502, + detail=( + "Failed to query MongoDB (TLS/connection issue). " + "On some hosts you may need a CA bundle; try installing certifi or set MONGO_TLS_CA_FILE. " + f"Original error: {type(e).__name__}: {e}" + ), + ) video_file = (movie or {}).get("video_file") or "" if not isinstance(video_file, str) or not video_file.strip(): @@ -287,5 +351,16 @@ def healthz(): "time": datetime.now(timezone.utc).isoformat(), "video_files_dir": str(_video_files_dir()), "mongo_db_name": _mongo_db_name() or None, + "openssl": ssl.OPENSSL_VERSION, } + +@app.on_event("shutdown") +def _shutdown(): + # Close the cached Mongo client cleanly. + try: + client = _mongo_client_cached() + client.close() + except Exception: + pass +