Sicherheits-Härtung und Bugfixes

This commit is contained in:
Patrick Asmus
2026-10-11 17:39:03 +02:00
parent c3f68ace96
commit 65b0b5ac0f
25 changed files with 2577 additions and 686 deletions
+170 -44
View File
@@ -1,16 +1,95 @@
import asyncio
import datetime
import glob as globmod
import logging
import os
import re
import shlex
import signal
import subprocess
import threading
import time
from pathlib import Path
from typing import Optional
from urllib.parse import urlsplit
from models import Recording, get_session, LogEntry
from config import DEFAULT_DOWNLOAD_PATH, validate_download_path
from models import Recording, get_session, LogEntry, iso_utc, utcnow
logger = logging.getLogger("recorder")
ALLOWED_URL_SCHEMES = ("http", "https", "rtmp", "rtmps", "rtsp", "rtsps")
PROTOCOL_WHITELIST = "http,https,tcp,tls,crypto,hls,httpproxy,rtmp,rtmps,rtmpt,rtmpts,ffrtmphttp,rtp,udp"
FFMPEG_FLAGS = {"-vn", "-an", "-sn", "-dn", "-shortest", "-copyts"}
FFMPEG_VALUE_OPTIONS = {
"-c", "-c:a", "-c:v", "-codec", "-codec:a", "-codec:v", "-acodec", "-vcodec",
"-b:a", "-b:v", "-ar", "-ac", "-map", "-metadata", "-metadata:s:a", "-t", "-to",
"-q:a", "-aq", "-af", "-filter:a", "-bsf:a", "-bsf:v", "-f", "-movflags",
"-segment_format", "-fflags", "-avoid_negative_ts", "-max_muxing_queue_size",
}
FORBIDDEN_FILTER_WORDS = ("movie", "sendcmd", "zmq", "file")
MAX_FFMPEG_ARGS_LEN = 500
MIN_STABLE_RUNTIME = 60
STALE_ERROR_MESSAGE = "Abgebrochen durch Neustart"
def validate_ffmpeg_args(args: str) -> list[str]:
"""Prüft zusätzliche ffmpeg-Argumente gegen eine Allowlist. Wirft ValueError."""
if not args:
return []
if len(args) > MAX_FFMPEG_ARGS_LEN:
raise ValueError(f"Zusätzliche ffmpeg-Argumente sind zu lang (max. {MAX_FFMPEG_ARGS_LEN} Zeichen)")
try:
tokens = shlex.split(args)
except ValueError as e:
raise ValueError(f"Zusätzliche ffmpeg-Argumente nicht lesbar: {e}")
i = 0
while i < len(tokens):
tok = tokens[i]
if tok in FFMPEG_FLAGS:
i += 1
continue
if tok in FFMPEG_VALUE_OPTIONS:
if i + 1 >= len(tokens):
raise ValueError(f"ffmpeg-Option '{tok}' benötigt einen Wert")
val = tokens[i + 1]
if not val or val.startswith("-"):
raise ValueError(f"Ungültiger Wert für ffmpeg-Option '{tok}': '{val}'")
if tok in ("-af", "-filter:a"):
low = val.lower()
for word in FORBIDDEN_FILTER_WORDS:
if word in low:
raise ValueError(f"Filter '{val}' ist nicht erlaubt ('{word}')")
i += 2
continue
raise ValueError(f"ffmpeg-Argument nicht erlaubt: '{tok}'")
return tokens
def validate_stream_url(url: str) -> str:
"""Prüft eine Stream-URL. Wirft ValueError."""
if not isinstance(url, str):
raise ValueError("Stream-URL fehlt")
url = url.strip()
if not url:
raise ValueError("Stream-URL fehlt")
if len(url) > 1000:
raise ValueError("Stream-URL ist zu lang (max. 1000 Zeichen)")
if any(ch.isspace() or ord(ch) < 32 or ord(ch) == 127 for ch in url):
raise ValueError("Stream-URL enthält Leer- oder Steuerzeichen")
try:
parts = urlsplit(url)
hostname = parts.hostname
except ValueError:
raise ValueError("Stream-URL ist ungültig")
if parts.scheme.lower() not in ALLOWED_URL_SCHEMES:
raise ValueError(f"Stream-URL muss mit {', '.join(s + '://' for s in ALLOWED_URL_SCHEMES)} beginnen")
if not parts.netloc or not hostname:
raise ValueError("Stream-URL enthält keinen Host")
return url
def detect_stream_type(url: str) -> str:
@@ -96,14 +175,27 @@ class RecordingManager:
self._get_ntfy().notify(event, title, message, job_ntfy_enabled=ntfy_enabled, priority=priority)
def _log(self, level: str, message: str, job_name: str = None):
py_level = logging.WARNING if level == "WARN" else getattr(logging, level, logging.INFO)
logger.log(py_level, message)
session = get_session()
try:
entry = LogEntry(level=level, message=message, job_name=job_name)
session.add(entry)
session.commit()
except Exception as e:
session.rollback()
logger.error("Log-Eintrag konnte nicht gespeichert werden: %s", e)
finally:
session.close()
def _download_path(self) -> str:
configured = self.config.get("recording", "download_path", default=DEFAULT_DOWNLOAD_PATH)
try:
return validate_download_path(configured)
except ValueError as e:
self._log("ERROR", f"Ungültiger Download-Pfad '{configured}': {e} – verwende {DEFAULT_DOWNLOAD_PATH}")
return DEFAULT_DOWNLOAD_PATH
def start_recording(
self,
job_id: Optional[int],
@@ -120,13 +212,16 @@ class RecordingManager:
plik_enabled: bool = False,
delete_after_upload: bool = False,
) -> dict:
if stream_type == "auto":
stream_url = validate_stream_url(stream_url)
validate_ffmpeg_args(extra_ffmpeg_args or "")
if not stream_type or stream_type == "auto":
stream_type = detect_stream_type(stream_url)
ext = output_format if output_format and output_format.lower() != "auto" else default_extension(stream_type)
sname = safe_name(name)
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
download_path = self.config.get("recording", "download_path", default="/app/data/recordings")
download_path = self._download_path()
output_dir = os.path.join(download_path, sname)
os.makedirs(output_dir, exist_ok=True)
@@ -139,7 +234,7 @@ class RecordingManager:
scheduled_stop = None
if max_duration and max_duration > 0:
scheduled_stop = datetime.datetime.utcnow() + datetime.timedelta(seconds=max_duration)
scheduled_stop = utcnow() + datetime.timedelta(seconds=max_duration)
session = get_session()
try:
@@ -206,12 +301,12 @@ class RecordingManager:
try:
while not stop_event.is_set():
cmd = self._build_ffmpeg_cmd(
stream_url, stream_type, output_file,
extra_ffmpeg_args, segment_mode, segment_duration,
)
log_fh = None
try:
cmd = self._build_ffmpeg_cmd(
stream_url, stream_type, output_file,
extra_ffmpeg_args, segment_mode, segment_duration,
)
log_path = os.path.join(output_dir, f".ffmpeg_{rec_id}.log")
log_fh = open(log_path, "a")
process = subprocess.Popen(
@@ -221,6 +316,8 @@ class RecordingManager:
stdin=subprocess.DEVNULL,
)
except Exception as e:
if log_fh:
log_fh.close()
self._update_recording(rec_id, status="error", error_message=str(e),
file_size=self._finalize_file_size(rec_id, segment_mode))
self._log("ERROR", f"ffmpeg konnte nicht gestartet werden: {e}", job_name)
@@ -229,6 +326,7 @@ class RecordingManager:
self._processes[rec_id] = process
self._update_recording(rec_id, status="running", pid=process.pid)
proc_started = time.monotonic()
process.wait()
retcode = process.returncode
@@ -253,6 +351,8 @@ class RecordingManager:
self._handle_post_recording(rec_id, output_dir, output_file, job_name, segment_mode)
return
if time.monotonic() - proc_started >= MIN_STABLE_RUNTIME:
retry = 0
retry += 1
if max_retries > 0 and retry >= max_retries:
self._update_recording(rec_id, status="error",
@@ -290,7 +390,11 @@ class RecordingManager:
self._cleanup(rec_id)
def _build_ffmpeg_cmd(self, url, stream_type, output_file, extra_args, segment_mode, segment_duration):
cmd = ["ffmpeg", "-nostdin", "-y", "-hide_banner", "-loglevel", "warning"]
url = validate_stream_url(url)
extra_tokens = validate_ffmpeg_args(extra_args or "")
cmd = ["ffmpeg", "-nostdin", "-y", "-hide_banner", "-loglevel", "warning",
"-protocol_whitelist", PROTOCOL_WHITELIST]
if stream_type == "hls":
cmd += ["-i", url, "-c", "copy", "-bsf:a", "aac_adtstoasc"]
@@ -300,8 +404,7 @@ class RecordingManager:
if segment_mode and segment_duration:
cmd += ["-f", "segment", "-segment_time", str(segment_duration), "-reset_timestamps", "1"]
if extra_args:
cmd += extra_args.split()
cmd += extra_tokens
cmd.append(output_file)
return cmd
@@ -328,23 +431,25 @@ class RecordingManager:
self._cleanup(rec_id)
return True
def extend_recording(self, rec_id: int, extra_seconds: int) -> bool:
def extend_recording(self, rec_id: int, extra_seconds: int) -> str:
"""Ergebnis: "ok", "not_found", "not_running" oder "unlimited"."""
session = get_session()
try:
rec = session.query(Recording).filter_by(id=rec_id).first()
if not rec or rec.status != "running":
return False
if rec.scheduled_stop:
rec.scheduled_stop = rec.scheduled_stop + datetime.timedelta(seconds=extra_seconds)
else:
rec.scheduled_stop = datetime.datetime.utcnow() + datetime.timedelta(seconds=extra_seconds)
if not rec:
return "not_found"
if rec.status != "running":
return "not_running"
if not rec.scheduled_stop:
return "unlimited"
rec.scheduled_stop = rec.scheduled_stop + datetime.timedelta(seconds=extra_seconds)
job_name = rec.job_name
session.commit()
self._log("INFO", f"Aufnahme verlängert um {format_elapsed(extra_seconds)}: {rec.job_name}", rec.job_name)
return True
finally:
session.close()
self._log("INFO", f"Aufnahme verlängert um {format_elapsed(extra_seconds)}: {job_name}", job_name)
return "ok"
def remove_duration_limit(self, rec_id: int) -> bool:
session = get_session()
@@ -388,7 +493,7 @@ class RecordingManager:
def check_scheduled_stops(self):
session = get_session()
try:
now = datetime.datetime.utcnow()
now = utcnow()
recs = session.query(Recording).filter(
Recording.status == "running",
Recording.scheduled_stop.isnot(None),
@@ -403,25 +508,30 @@ class RecordingManager:
self.stop_recording(rec_id)
def cleanup_stale(self):
# Nach einem Neustart kann kein ffmpeg-Kindprozess mehr laufen
session = get_session()
try:
recs = session.query(Recording).filter(Recording.status.in_(["running", "starting"])).all()
now = utcnow()
for rec in recs:
if rec.pid:
try:
os.kill(rec.pid, 0)
except (ProcessLookupError, OSError):
rec.status = "error"
rec.error_message = "Process vanished"
rec.stopped_at = datetime.datetime.utcnow()
rec.status = "error"
rec.error_message = STALE_ERROR_MESSAGE
rec.stopped_at = now
try:
files = [rec.output_file] if rec.output_file else []
rec.file_size, _ = self._calc_recording_size(files, bool(rec.segment_mode))
except Exception:
pass
session.commit()
if recs:
logger.warning("%d verwaiste Aufnahme(n) als abgebrochen markiert", len(recs))
finally:
session.close()
def _recording_to_dict(self, rec: Recording) -> dict:
elapsed = 0
if rec.started_at:
end = rec.stopped_at or datetime.datetime.utcnow()
end = rec.stopped_at or utcnow()
elapsed = int((end - rec.started_at).total_seconds())
total_size = 0
@@ -434,7 +544,7 @@ class RecordingManager:
remaining = None
if rec.scheduled_stop and rec.status == "running":
diff = (rec.scheduled_stop - datetime.datetime.utcnow()).total_seconds()
diff = (rec.scheduled_stop - utcnow()).total_seconds()
remaining = max(0, int(diff))
return {
@@ -447,9 +557,9 @@ class RecordingManager:
"output_file": rec.output_file,
"pid": rec.pid,
"status": rec.status,
"started_at": rec.started_at.isoformat() if rec.started_at else None,
"stopped_at": rec.stopped_at.isoformat() if rec.stopped_at else None,
"scheduled_stop": rec.scheduled_stop.isoformat() if rec.scheduled_stop else None,
"started_at": iso_utc(rec.started_at),
"stopped_at": iso_utc(rec.stopped_at),
"scheduled_stop": iso_utc(rec.scheduled_stop),
"segment_mode": rec.segment_mode,
"is_scheduled": rec.is_scheduled,
"error_message": rec.error_message,
@@ -463,16 +573,31 @@ class RecordingManager:
"plik_url": rec.plik_url,
}
@staticmethod
def _segment_files(pattern: str) -> list[str]:
glob_pattern = globmod.escape(pattern).replace("%03d", "*")
return sorted(
f for f in globmod.glob(glob_pattern)
if os.path.isfile(f) and not os.path.basename(f).startswith(".")
)
def _resolve_output_files(self, patterns: list[str], segment_mode: bool) -> list[str]:
files = []
for of in patterns:
if segment_mode:
files.extend(self._segment_files(of))
elif os.path.isfile(of) and os.path.getsize(of) > 0:
files.append(of)
return [f for f in files if not os.path.basename(f).startswith(".")]
def _calc_recording_size(self, output_files: list[str], segment_mode: bool) -> tuple[int, int]:
total_size = 0
segment_count = 0
for of in output_files:
if segment_mode:
pattern = of.replace('%03d', '*')
for f in globmod.glob(pattern):
if os.path.isfile(f):
total_size += os.path.getsize(f)
segment_count += 1
for f in self._segment_files(of):
total_size += os.path.getsize(f)
segment_count += 1
else:
if os.path.isfile(of):
total_size += os.path.getsize(of)
@@ -494,7 +619,7 @@ class RecordingManager:
for k, v in kwargs.items():
setattr(rec, k, v)
if kwargs.get("status") in ("stopped", "completed", "error"):
rec.stopped_at = datetime.datetime.utcnow()
rec.stopped_at = utcnow()
session.commit()
finally:
session.close()
@@ -503,9 +628,10 @@ class RecordingManager:
job_name: str, segment_mode: bool):
opts = self._rec_options.get(rec_id, {})
if opts.get("plik_enabled"):
patterns = list(self._output_files.get(rec_id, [output_file]))
files = self._resolve_output_files(patterns, segment_mode)
self._get_plik().upload_recording(
output_dir=output_dir,
output_file=output_file,
files=files,
job_name=job_name,
segment_mode=segment_mode,
delete_after=opts.get("delete_after_upload", False),