From 812590d262d57763a21a864488721bc442fd6fc3 Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Mon, 22 Dec 2025 03:20:41 -0500 Subject: [PATCH] Add Python-only Let's Encrypt certificate management (#48) Co-authored-by: Cursor Agent --- .gitignore | 5 +- python_video_server/README.md | 46 ++++ .../__pycache__/letsencrypt.cpython-312.pyc | Bin 0 -> 9565 bytes python_video_server/letsencrypt.py | 242 ++++++++++++++++++ python_video_server/requirements.txt | 1 + 5 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 python_video_server/__pycache__/letsencrypt.cpython-312.pyc create mode 100644 python_video_server/letsencrypt.py 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 0000000000000000000000000000000000000000..0c8d9354c982a315afd6ba7eb77c056c5d98cb4d GIT binary patch literal 9565 zcmdryTWlLwb~EIV91fq7C|j1Cc>Ihgo0PqA5?kJ+RwTub*tB9RFU2W6;*2QDeC5ti zvba>1YXn%k_QEo{fD|-EjdnlM+9(3_CoLA(N{&6E0Xw`o7 zoJ-D-k{R!IKYIniS&j4Wg|F{ThL zF`bA>nj$7tH7BKG${pCjSCs{EeC)h9ok%Jw4xE5lQ?OOsS~j7?aNlBPN|O?4Y?9Kl zNcT@GqDFG6rpT@5gw0Rj##0$NmsG@gB4t&&*8yA*PS-fGn3ZBzrMNO6_NOzVx&ZT} z#48P$E8^6gh~~E@a^*@!nvV*sp4^87Y}bEA(uk!$%I`C>d7Eh}jI$ z#AAnpLxL!(IXNSS#>6bWX)%;)l%jJ9S;<7}cNk78nyR!s8+{NFQYfUPq(o9&$mC8c z*Q8W7sf1&h6m~(MoPo|XzLVK=XnuY^+){5qW(nL#OiN_acth|2_=t@+qc7OHSEIeY z=gm8Zi=aR|T44f~Vg~!1Z3gMK1HFU<`VahB?%)xM+Q1}%RP>|JKQaYW{B)l3m0(EX& z(q@7doj;=~M4Cz}I(H7JARnI5w5&QbI4H$ZN@(uzx5833(fV-V42ciQfG;sELl*Kc zZh{AVi38UD=5etGfzuCAH3L6(oj_rk(Hcb0Fq*{xGZ?|F&1h+U4Q4TGZ?+jFna#Tx z=4qoYF+od2x6wxmas-*E&PSto_tB{CAc~qv&MCS*D*;Vvf6lDq)El9}`3$+L0(~ii zZSZHX1=GXX1-&CGE7MXgsYRzGRf)p&5g$ym4}R)F$coHkFXQR>Vb86|s(Vk_y{F>d zx9UDvb|3sSUUt8*>UyQ@dZpqzS>jF-55ULFD}oAM3B5i$v@SCR<{GO~em~m^1wfc& zi*c2s%pEpni8C?F`+FH?ku9*Z_z+u?ak(mcJMeUfyO!JgIcCwa$i|sPt7blwF~cHP zu#7VA2kUDUxH%T)u3IxXP3KfaNvn7o zN}R+CCEXjPvql?`@N(7d zyL0yD*`*I|pRc<8cTV3tz4XQpUavU-rp7aFU-9gJeci$MF0d-91}ojeuW*0a3E3tb zLA0hgQiB5GNP{riV2Jr%EI?T(`8*BE0#Mf92xV=LJ2EG84{Y}vWDaj_X@`SPKv{n) zD7IiJSf>Fmg7%1Ruk&A>L%P+uWG1do0Q+;)$y(KUssuzcSA!OU_o&-aSAk4}oX#60 zs|)oxkbHF>=c$))E-a~d$%rI%9C1ehZ*qiEyGj7G;q_cPBopR^?)sAqv zBV6e?Ts&L#_pH2F_U~Tx4U~NY72l!a>2^(=ca;m2xxmWAr=uk<@Q^!F<=o$od@r(c z^wWLC$U|=MiMp$M>Ds-Xk}Fu^f={v!BtME8KZ3$CC~}|>GD%CcBU=1C8~vuT?3wE zO{9UpPzWvw!Z&EuIf=yQbVp;f&MGSM z*bjW+#{8Ya&BCfrEc?VC1@6gz)(bdQafM1;=szF(7S;lPCW;iy*2^%*BDZK;w98iD61D>Kv^8aW3rcea zTFQ==R-4(%x3t>LR;Mh)-HVQbmADHI*`=9e5x3+)FfTgNF4|75G(4QqUSL7?fgD@p zq2FELjrQ9vkX;VoQJzS?%TR*>cMYXH|6Ei5ZD|(!K|M}MAyoke29}Q;+~^2}(qQBO zPJu!PI`Ev5cZEVyoG402%cvWU#_SvJP^iwxLcp{(gir`snNdb&W|TpP3~38lMYkz2 zS(P?;&`hZ@l(&TBpq(IAkPv3b-9WZ#MDIH{79GEMVf4+Bi8B`>(a|#($3`YDTzohB z=EZY*4|HCd7(R6-a%yu=&WsHLig%MS=_ERxl;SEuO1XqXzJv^@#ml%m?wq)J;`Ylx1En3|ig&QY5B|*6Nvp3` zydx!kcbv8nz~Kq)B!_EL*#wZlL

hbQ&f#wyunUN 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 literal 0 HcmV?d00001 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