Add Python-only Let's Encrypt certificate management
Co-authored-by: mathieumoran1 <[email protected]>
This commit is contained in:
co-authored by
mathieumoran1
parent
1b27b378ec
commit
45883c757d
+4
-1
@@ -136,4 +136,7 @@ dist
|
||||
.pnp.*
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
- `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
|
||||
pymongo==4.10.1
|
||||
PyJWT==2.10.1
|
||||
certbot>=2.11.0,<3
|
||||
|
||||
Reference in New Issue
Block a user