Sicherheits-Härtung und Bugfixes
This commit is contained in:
+170
-44
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user