183 lines
5.9 KiB
Python
183 lines
5.9 KiB
Python
import base64
|
|
import hashlib
|
|
import hmac
|
|
import os
|
|
import secrets
|
|
import tempfile
|
|
import threading
|
|
|
|
import yaml
|
|
|
|
DEFAULT_DOWNLOAD_PATH = "/app/data/recordings"
|
|
|
|
DEFAULT_CONFIG = {
|
|
"recording": {
|
|
"download_path": DEFAULT_DOWNLOAD_PATH,
|
|
"max_retries": 5,
|
|
"retry_delay": 5,
|
|
},
|
|
"server": {
|
|
"host": "0.0.0.0",
|
|
"port": 8484,
|
|
},
|
|
"auth": {
|
|
"enabled": False,
|
|
"username": "admin",
|
|
"password_hash": "",
|
|
},
|
|
"ntfy": {
|
|
"url": "",
|
|
"token": "",
|
|
"events": "error",
|
|
},
|
|
"plik": {
|
|
"url": "",
|
|
"api_key": "",
|
|
"ttl": "30d",
|
|
},
|
|
"schedule": {
|
|
"default_pre_buffer": 5,
|
|
"default_post_buffer": 5,
|
|
},
|
|
"logging": {
|
|
"level": "INFO",
|
|
},
|
|
}
|
|
|
|
PBKDF2_ITERATIONS = 600000
|
|
|
|
|
|
LEGACY_DEFAULT_PASSWORD = "stream-recorder"
|
|
|
|
|
|
def hash_password(password: str, iterations: int | None = None) -> str:
|
|
iterations = iterations or PBKDF2_ITERATIONS
|
|
salt = secrets.token_bytes(16)
|
|
dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
|
|
return "pbkdf2_sha256${}${}${}".format(
|
|
iterations,
|
|
base64.b64encode(salt).decode("ascii"),
|
|
base64.b64encode(dk).decode("ascii"),
|
|
)
|
|
|
|
|
|
def verify_password(password: str, encoded: str) -> bool:
|
|
try:
|
|
algo, iterations, salt_b64, hash_b64 = encoded.split("$", 3)
|
|
if algo != "pbkdf2_sha256":
|
|
return False
|
|
salt = base64.b64decode(salt_b64)
|
|
expected = base64.b64decode(hash_b64)
|
|
dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, int(iterations))
|
|
return hmac.compare_digest(dk, expected)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def recording_roots() -> list[str]:
|
|
raw = os.environ.get("RECORDING_ROOTS", os.pathsep.join(["/app/data/recordings", "/recordings"]))
|
|
return [os.path.realpath(r) for r in raw.split(os.pathsep) if r.strip()]
|
|
|
|
|
|
def validate_download_path(path: str) -> str:
|
|
"""Gibt den normalisierten Pfad zurück oder wirft ValueError."""
|
|
if not isinstance(path, str) or not path.strip():
|
|
raise ValueError("Download-Pfad darf nicht leer sein")
|
|
if "\x00" in path:
|
|
raise ValueError("Download-Pfad enthält ungültige Zeichen")
|
|
if not os.path.isabs(path):
|
|
raise ValueError("Download-Pfad muss absolut sein")
|
|
real = os.path.realpath(path)
|
|
roots = recording_roots()
|
|
for root in roots:
|
|
if real == root or real.startswith(root.rstrip(os.sep) + os.sep):
|
|
return real
|
|
raise ValueError(f"Download-Pfad muss innerhalb von {', '.join(roots)} liegen")
|
|
|
|
|
|
class Config:
|
|
def __init__(self, config_path: str = "/app/data/config.yml"):
|
|
self.config_path = config_path
|
|
self.data = {}
|
|
self.lock = threading.RLock()
|
|
self.generated_password = None
|
|
self.password_migrated = False
|
|
self.default_password_active = False
|
|
self.load()
|
|
|
|
def load(self):
|
|
with self.lock:
|
|
self.data = _deep_copy(DEFAULT_CONFIG)
|
|
if os.path.exists(self.config_path):
|
|
with open(self.config_path, "r", encoding="utf-8") as f:
|
|
user_config = yaml.safe_load(f) or {}
|
|
_deep_merge(self.data, user_config)
|
|
auth = self.data.setdefault("auth", {})
|
|
if "password" in auth:
|
|
plain = auth.pop("password")
|
|
if plain and not auth.get("password_hash"):
|
|
auth["password_hash"] = hash_password(str(plain))
|
|
self.password_migrated = True
|
|
self.save()
|
|
# Altes Standardpasswort gilt als nicht gesetzt, sonst würde
|
|
# "Auth aktivieren" ohne neues Passwort ein bekanntes Passwort scharf schalten.
|
|
pw_hash = auth.get("password_hash")
|
|
if pw_hash and verify_password(LEGACY_DEFAULT_PASSWORD, pw_hash):
|
|
if auth.get("enabled"):
|
|
self.default_password_active = True
|
|
else:
|
|
auth["password_hash"] = ""
|
|
self.save()
|
|
else:
|
|
password = secrets.token_urlsafe(12)
|
|
self.data["auth"]["enabled"] = True
|
|
self.data["auth"]["username"] = "admin"
|
|
self.data["auth"]["password_hash"] = hash_password(password)
|
|
self.generated_password = password
|
|
self.save()
|
|
|
|
def save(self):
|
|
with self.lock:
|
|
directory = os.path.dirname(self.config_path) or "."
|
|
os.makedirs(directory, exist_ok=True)
|
|
fd, tmp_path = tempfile.mkstemp(prefix=".config.", suffix=".tmp", dir=directory)
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
yaml.dump(self.data, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
os.chmod(tmp_path, 0o600)
|
|
os.replace(tmp_path, self.config_path)
|
|
except BaseException:
|
|
try:
|
|
os.remove(tmp_path)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
|
|
def get(self, *keys, default=None):
|
|
with self.lock:
|
|
val = self.data
|
|
for key in keys:
|
|
if isinstance(val, dict) and key in val:
|
|
val = val[key]
|
|
else:
|
|
return default
|
|
return val
|
|
|
|
|
|
def _deep_copy(d):
|
|
if isinstance(d, dict):
|
|
return {k: _deep_copy(v) for k, v in d.items()}
|
|
if isinstance(d, list):
|
|
return [_deep_copy(v) for v in d]
|
|
return d
|
|
|
|
|
|
def _deep_merge(base, override):
|
|
for key, value in override.items():
|
|
if key in base and isinstance(base[key], dict) and isinstance(value, dict):
|
|
_deep_merge(base[key], value)
|
|
else:
|
|
base[key] = value
|