video_monitor/app/utils/Secrets.py

140 lines
4.6 KiB
Python
Raw Normal View History

2026-08-30 22:22:11 +08:00
"""Runtime secret management.
Secrets are loaded from environment variables first. For local/offline installs,
missing values are generated once into ``.runtime-secrets.json`` at the project
root. That file must never be committed or served by the web application.
"""
import json
import os
import secrets
import threading
import base64
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
RUNTIME_SECRETS_FILE = Path(
os.environ.get("MONITOR_RUNTIME_SECRETS_FILE", PROJECT_ROOT / ".runtime-secrets.json")
).resolve()
_ENV_NAMES = {
"django_secret_key": "MONITOR_DJANGO_SECRET_KEY",
"internal_api_secret": "MONITOR_INTERNAL_API_SECRET",
"media_secret": "MONITOR_MEDIA_SECRET",
"sip_server_password": "MONITOR_SIP_SERVER_PASSWORD",
"sip_server_nonce": "MONITOR_SIP_SERVER_NONCE",
"credential_encryption_key": "MONITOR_CREDENTIAL_ENCRYPTION_KEY",
}
_CACHE = None
_LOCK = threading.RLock()
def _new_secret_values():
return {
"django_secret_key": secrets.token_urlsafe(64),
"internal_api_secret": secrets.token_urlsafe(48),
"media_secret": secrets.token_urlsafe(32),
"sip_server_password": secrets.token_urlsafe(24),
"sip_server_nonce": secrets.token_hex(16),
"credential_encryption_key": base64.urlsafe_b64encode(os.urandom(32)).decode("ascii"),
}
def _read_file():
with open(RUNTIME_SECRETS_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
raise RuntimeError("runtime secrets file must contain a JSON object")
return data
def _create_file_exclusive(data):
RUNTIME_SECRETS_FILE.parent.mkdir(parents=True, exist_ok=True)
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
fd = os.open(str(RUNTIME_SECRETS_FILE), flags, 0o600)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
f.write("\n")
except Exception:
try:
os.unlink(RUNTIME_SECRETS_FILE)
except OSError:
pass
raise
try:
os.chmod(RUNTIME_SECRETS_FILE, 0o600)
except OSError:
pass
def _write_file_atomic(data):
RUNTIME_SECRETS_FILE.parent.mkdir(parents=True, exist_ok=True)
temp_path = RUNTIME_SECRETS_FILE.with_suffix(RUNTIME_SECRETS_FILE.suffix + ".tmp")
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
fd = os.open(str(temp_path), flags, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
f.write("\n")
os.replace(temp_path, RUNTIME_SECRETS_FILE)
try:
os.chmod(RUNTIME_SECRETS_FILE, 0o600)
except OSError:
pass
def _load_or_create():
global _CACHE
with _LOCK:
if _CACHE is not None:
return dict(_CACHE)
if RUNTIME_SECRETS_FILE.exists():
data = _read_file()
else:
data = _new_secret_values()
try:
_create_file_exclusive(data)
except FileExistsError:
data = _read_file()
missing = [name for name in _ENV_NAMES if not data.get(name)]
if missing:
generated = _new_secret_values()
for name in missing:
data[name] = generated[name]
_write_file_atomic(data)
_CACHE = data
return dict(_CACHE)
def get_runtime_secret(name):
if name not in _ENV_NAMES:
raise KeyError("unknown runtime secret: %s" % name)
env_value = os.environ.get(_ENV_NAMES[name], "").strip()
if env_value:
return env_value
return str(_load_or_create()[name])
def rotate_runtime_secrets():
"""Rotate service/session secrets while preserving the data-encryption key."""
global _CACHE
with _LOCK:
backup = None
previous = {}
if RUNTIME_SECRETS_FILE.exists():
import shutil
from datetime import datetime
backup = RUNTIME_SECRETS_FILE.with_name(
RUNTIME_SECRETS_FILE.name + ".backup-" + datetime.now().strftime("%Y%m%d%H%M%S")
)
shutil.copy2(RUNTIME_SECRETS_FILE, backup)
previous = _read_file()
data = _new_secret_values()
# Rotating this value without decrypting and re-encrypting every database
# row would make stored third-party credentials unrecoverable.
if previous.get("credential_encryption_key"):
data["credential_encryption_key"] = previous["credential_encryption_key"]
_write_file_atomic(data)
_CACHE = data
return str(backup) if backup else ""