Add Python-only Let's Encrypt certificate management (#48)

Co-authored-by: Cursor Agent <[email protected]>
This commit was merged in pull request #48.
This commit is contained in:
Mathieu Moran
2025-12-22 03:20:41 -05:00
committed by GitHub
co-authored by Cursor Agent
parent 1b27b378ec
commit 812590d262
5 changed files with 293 additions and 1 deletions
+3
View File
@@ -137,3 +137,6 @@ dist
# ayylmao # ayylmao
ayylmao.js ayylmao.js
# Python video server - generated TLS material (certbot state)
python_video_server/certs/
+46
View File
@@ -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 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 [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`
### 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 ## Endpoints
- `POST /api/video/session` - `POST /api/video/session`
+242
View File
@@ -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 [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 \
--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/<domain>/{fullchain.pem,privkey.pem,...}
# - config/renewal/<domain>.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())
+1
View File
@@ -2,3 +2,4 @@ fastapi==0.115.6
uvicorn[standard]==0.34.0 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