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
```
### 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:
- `python_video_server/certs/config/live/<primary-domain>/fullchain.pem`
- `python_video_server/certs/config/live/<primary-domain>/privkey.pem`
+199 -18
View File
@@ -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 [email protected] \
--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",
+1
View File
@@ -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
+83 -8
View File
@@ -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