diff --git a/.gitignore b/.gitignore index 5af23d6..db75230 100644 --- a/.gitignore +++ b/.gitignore @@ -136,4 +136,7 @@ dist .pnp.* # ayylmao -ayylmao.js \ No newline at end of file +ayylmao.js + +# Python video server - generated TLS material (certbot state) +python_video_server/certs/ \ No newline at end of file diff --git a/python_video_server/README.md b/python_video_server/README.md index 20990cf..d7517f4 100644 --- a/python_video_server/README.md +++ b/python_video_server/README.md @@ -17,6 +17,52 @@ export VIDEO_SESSION_MAX_AGE_SECONDS="28800" # optional (default: 8h uvicorn python_video_server.server:app --host 0.0.0.0 --port 8000 ``` +## HTTPS (Let’s Encrypt, Python-only) + +This server can be served over HTTPS by running `uvicorn` with `--ssl-certfile` and `--ssl-keyfile`. +To obtain/renew a trusted cert from Let’s Encrypt **without installing system certbot**, this repo includes a small wrapper that uses the **`certbot` Python package**. + +### 1) Issue a certificate (HTTP-01 standalone) + +Requirements: +- Your domain’s DNS `A/AAAA` records must point to this machine. +- Port **80** must be reachable from the internet for the ACME challenge. + +```bash +python3 -m pip install -r python_video_server/requirements.txt + +# Staging by default (recommended first). Add --production when it works. +sudo -E python3 -m python_video_server.letsencrypt certonly \ + --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` + +### 2) Run the video server on 443 with TLS + +```bash +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-keyfile python_video_server/certs/config/live/example.com/privkey.pem +``` + +Notes: +- Binding to ports **80/443** usually requires `sudo` (or a reverse proxy / port forwarding). +- If you’re behind a proxy/ingress, keep sending `X-Forwarded-Proto: https` so `/api/video/session` sets `Secure` cookies correctly. + +### 3) Renew certificates + +```bash +# Dry-run renewal (safe) +sudo -E python3 -m python_video_server.letsencrypt renew --dry-run + +# Actual renew (typically run via cron/systemd timer) +sudo -E python3 -m python_video_server.letsencrypt renew --production +``` + ## Endpoints - `POST /api/video/session` diff --git a/python_video_server/__pycache__/letsencrypt.cpython-312.pyc b/python_video_server/__pycache__/letsencrypt.cpython-312.pyc new file mode 100644 index 0000000..0c8d935 Binary files /dev/null and b/python_video_server/__pycache__/letsencrypt.cpython-312.pyc differ diff --git a/python_video_server/letsencrypt.py b/python_video_server/letsencrypt.py new file mode 100644 index 0000000..c1abc7a --- /dev/null +++ b/python_video_server/letsencrypt.py @@ -0,0 +1,242 @@ +""" +Let’s Encrypt certificate issuance/renewal using Python-only certbot. + +Why this exists: +- You already run the server with `uvicorn`. +- Uvicorn can serve HTTPS when given cert/key PEM files. +- The missing piece is obtaining and renewing a trusted certificate. + +This module wraps the *certbot Python package* (no system `certbot` binary required). + +Typical usage (standalone HTTP-01, binds to port 80): + sudo -E python -m python_video_server.letsencrypt certonly \ + --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 \ + --ssl-keyfile python_video_server/certs/config/live/example.com/privkey.pem +""" + +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path +from typing import Iterable, List + + +LE_PRODUCTION_DIRECTORY_URL = "https://acme-v02.api.letsencrypt.org/directory" +LE_STAGING_DIRECTORY_URL = "https://acme-staging-v02.api.letsencrypt.org/directory" + + +def _default_base_dir() -> Path: + # Keep certbot state inside the repo by default (and gitignored). + # This mirrors certbot's default directory structure: + # - config/live//{fullchain.pem,privkey.pem,...} + # - config/renewal/.conf + # - work/* + # - logs/* + return (Path(__file__).resolve().parent / "certs").resolve() + + +def _split_domains(domains_csv: str) -> List[str]: + domains = [d.strip() for d in (domains_csv or "").split(",") if d.strip()] + # Deduplicate while preserving order + out: List[str] = [] + seen = set() + for d in domains: + if d not in seen: + out.append(d) + seen.add(d) + return out + + +def _ensure_dirs(base_dir: Path) -> tuple[Path, Path, Path]: + config_dir = (base_dir / "config").resolve() + work_dir = (base_dir / "work").resolve() + logs_dir = (base_dir / "logs").resolve() + config_dir.mkdir(parents=True, exist_ok=True) + work_dir.mkdir(parents=True, exist_ok=True) + logs_dir.mkdir(parents=True, exist_ok=True) + return config_dir, work_dir, logs_dir + + +def _run_certbot(argv: Iterable[str]) -> int: + try: + # certbot is a Python package dependency (installed via pip). + # Importing here keeps the server import path clean. + import certbot.main # type: ignore + except Exception as e: + raise SystemExit( + "Missing dependency: certbot is not installed.\n" + "Install it with:\n" + " python -m pip install -r python_video_server/requirements.txt\n" + f"\nOriginal import error: {e!r}" + ) + return int(certbot.main.main(list(argv))) + + +def _common_certbot_flags( + *, + email: str, + domains: List[str], + base_dir: Path, + production: bool, +) -> List[str]: + if not email.strip(): + raise SystemExit("--email is required") + if not domains: + raise SystemExit("--domains is required (comma-separated)") + + config_dir, work_dir, logs_dir = _ensure_dirs(base_dir) + + args: List[str] = [ + "--non-interactive", + "--agree-tos", + "--email", + email.strip(), + "--config-dir", + str(config_dir), + "--work-dir", + str(work_dir), + "--logs-dir", + str(logs_dir), + # Keep key type modern; certbot defaults may vary by version. + "--key-type", + "ecdsa", + ] + + if production: + args += ["--server", LE_PRODUCTION_DIRECTORY_URL] + else: + args += ["--server", LE_STAGING_DIRECTORY_URL] + + for d in domains: + args += ["-d", d] + + return args + + +def cmd_certonly(args: argparse.Namespace) -> int: + base_dir = Path(args.base_dir).expanduser().resolve() if args.base_dir else _default_base_dir() + 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, + ), + ] + + if args.force_renewal: + certbot_args.append("--force-renewal") + + if args.test_cert: + certbot_args.append("--test-cert") + + return _run_certbot(certbot_args) + + +def cmd_renew(args: argparse.Namespace) -> int: + base_dir = Path(args.base_dir).expanduser().resolve() if args.base_dir else _default_base_dir() + production = bool(args.production) + config_dir, work_dir, logs_dir = _ensure_dirs(base_dir) + + certbot_args: List[str] = [ + "renew", + "--non-interactive", + "--config-dir", + str(config_dir), + "--work-dir", + str(work_dir), + "--logs-dir", + str(logs_dir), + "--key-type", + "ecdsa", + ] + + certbot_args += ["--server", LE_PRODUCTION_DIRECTORY_URL if production else LE_STAGING_DIRECTORY_URL] + + if args.force_renewal: + certbot_args.append("--force-renewal") + if args.dry_run: + certbot_args.append("--dry-run") + + return _run_certbot(certbot_args) + + +def _build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="python -m python_video_server.letsencrypt", + description="Python-only Let’s Encrypt cert issuance/renewal (wraps certbot package).", + ) + p.add_argument( + "--base-dir", + default=os.environ.get("LETSENCRYPT_BASE_DIR", ""), + help="Where certbot state is stored (default: python_video_server/certs).", + ) + p.add_argument( + "--production", + action="store_true", + help="Use Let’s Encrypt production endpoint (default uses staging).", + ) + + sub = p.add_subparsers(dest="cmd", required=True) + + c = sub.add_parser("certonly", help="Issue (or re-issue) a certificate via HTTP-01 standalone.") + c.add_argument("--email", required=True, help="Email for Let’s Encrypt account registration.") + c.add_argument( + "--domains", + required=True, + help="Comma-separated domains (e.g. example.com,www.example.com). The first is used as the primary cert name.", + ) + c.add_argument( + "--http-01-port", + default=int(os.environ.get("LE_HTTP01_PORT", "80")), + type=int, + help="Port to bind for HTTP-01 challenge (default: 80).", + ) + c.add_argument("--force-renewal", action="store_true", help="Force issuance even if not close to expiry.") + c.add_argument( + "--test-cert", + action="store_true", + help="Obtain a test certificate from staging (alias for staging behavior; kept for familiarity).", + ) + c.set_defaults(_handler=cmd_certonly) + + r = sub.add_parser("renew", help="Renew any existing certificates in the base-dir.") + r.add_argument("--dry-run", action="store_true", help="Perform a dry-run renewal.") + r.add_argument("--force-renewal", action="store_true", help="Force renewal even if not due.") + r.set_defaults(_handler=cmd_renew) + + return p + + +def main(argv: List[str] | None = None) -> int: + parser = _build_parser() + ns = parser.parse_args(argv if argv is not None else sys.argv[1:]) + handler = getattr(ns, "_handler", None) + if handler is None: + raise SystemExit("No command selected") + return int(handler(ns)) + + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/python_video_server/requirements.txt b/python_video_server/requirements.txt index b20c5a4..863ae3a 100644 --- a/python_video_server/requirements.txt +++ b/python_video_server/requirements.txt @@ -2,3 +2,4 @@ fastapi==0.115.6 uvicorn[standard]==0.34.0 pymongo==4.10.1 PyJWT==2.10.1 +certbot>=2.11.0,<3