140 lines
4.6 KiB
Python
140 lines
4.6 KiB
Python
|
|
"""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 ""
|