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 0c8d935..709b682 100644 Binary files a/python_video_server/__pycache__/letsencrypt.cpython-312.pyc and b/python_video_server/__pycache__/letsencrypt.cpython-312.pyc differ diff --git a/python_video_server/__pycache__/server.cpython-312.pyc b/python_video_server/__pycache__/server.cpython-312.pyc index 9f3aa7b..f5a9b6b 100644 Binary files a/python_video_server/__pycache__/server.cpython-312.pyc and b/python_video_server/__pycache__/server.cpython-312.pyc differ 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 +