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:
co-authored by
Cursor Agent
parent
1b27b378ec
commit
812590d262
@@ -137,3 +137,6 @@ dist
|
|||||||
|
|
||||||
# ayylmao
|
# ayylmao
|
||||||
ayylmao.js
|
ayylmao.js
|
||||||
|
|
||||||
|
# Python video server - generated TLS material (certbot state)
|
||||||
|
python_video_server/certs/
|
||||||
@@ -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`
|
||||||
|
|||||||
Binary file not shown.
@@ -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())
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user