Dns validation for https #49

Merged
quoije merged 5 commits from cursor/dns-validation-for-https-20f4 into 2026-video.backend 2025-12-22 04:16:45 -05:00
6 changed files with 315 additions and 26 deletions
+32
View File
@@ -37,6 +37,38 @@ 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, 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 [email protected] \
--domains example.com,www.example.com
```
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`
+182 -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,121 @@ 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 _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: 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 +209,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 +219,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 +232,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 +251,49 @@ 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.
#
# 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 = [ certbot_args = [
"certonly", "certonly",
"--standalone", "--standalone",
@@ -141,6 +309,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 +381,18 @@ 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(
"--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("--force-renewal", action="store_true", help="Force issuance even if not close to expiry.")
c.add_argument( c.add_argument(
"--test-cert", "--test-cert",
+1
View File
@@ -3,3 +3,4 @@ uvicorn[standard]==0.34.0
pymongo==4.10.1 pymongo==4.10.1
PyJWT==2.10.1 PyJWT==2.10.1
certbot>=2.11.0,<3 certbot>=2.11.0,<3
certifi==2025.11.12
+77 -2
View File
@@ -1,6 +1,8 @@
import mimetypes import mimetypes
import os import os
import posixpath import posixpath
import ssl
from functools import lru_cache
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Generator, Optional, Tuple 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.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
from pymongo import MongoClient from pymongo import MongoClient
from pymongo.errors import PyMongoError
def _env_str(name: str, default: str = "") -> str: def _env_str(name: str, default: str = "") -> str:
@@ -49,6 +52,12 @@ def _mongo_db_name() -> str:
except Exception: except Exception:
return "" 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: def _video_files_dir() -> Path:
# Read env at runtime (important on Windows/uvicorn worker reloads). # Read env at runtime (important on Windows/uvicorn worker reloads).
@@ -143,7 +152,51 @@ def _mongo_client() -> MongoClient:
uri = _mongo_uri() uri = _mongo_uri()
if not uri: if not uri:
raise HTTPException(status_code=500, detail="MONGO_URI is not configured") 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() app = FastAPI()
@@ -211,15 +264,26 @@ def stream_video(
except Exception: except Exception:
raise HTTPException(status_code=400, detail="Invalid movie id") raise HTTPException(status_code=400, detail="Invalid movie id")
with _mongo_client() as client:
db_name = _mongo_db_name() db_name = _mongo_db_name()
if not db_name: if not db_name:
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail="Mongo DB name not configured. Add it to MONGO_URI (…/dbname) or set MONGO_DB_NAME.", 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] db = client[db_name]
movie = db["movies"].find_one({"_id": oid}, {"video_file": 1}) 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 "" video_file = (movie or {}).get("video_file") or ""
if not isinstance(video_file, str) or not video_file.strip(): if not isinstance(video_file, str) or not video_file.strip():
@@ -287,5 +351,16 @@ def healthz():
"time": datetime.now(timezone.utc).isoformat(), "time": datetime.now(timezone.utc).isoformat(),
"video_files_dir": str(_video_files_dir()), "video_files_dir": str(_video_files_dir()),
"mongo_db_name": _mongo_db_name() or None, "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