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
+237 -112
View File
@@ -1,7 +1,13 @@
import datetime
import logging
import os
import re
from apscheduler.schedulers.background import BackgroundScheduler
from sqlalchemy import or_
from models import Job, Recording, get_session
from models import Job, Recording, get_session, prune_logs, utcnow
logger = logging.getLogger("scheduler")
DAY_MAP = {
"mo": 1, "mon": 1, "montag": 1,
@@ -13,6 +19,8 @@ DAY_MAP = {
"so": 7, "sun": 7, "sonntag": 7,
}
_TIME_RE = re.compile(r"^([01]\d|2[0-3]):([0-5]\d)$")
def _day_to_num(day: str) -> int:
return DAY_MAP.get(day.lower().strip(), 0)
@@ -27,6 +35,8 @@ def _matches_day(today_num: int, schedule: str) -> bool:
parts = entry.split("-", 1)
start = _day_to_num(parts[0])
end = _day_to_num(parts[1])
if not start or not end:
continue
if start <= end:
if start <= today_num <= end:
return True
@@ -39,7 +49,69 @@ def _matches_day(today_num: int, schedule: str) -> bool:
return False
def is_valid_time(t) -> bool:
return isinstance(t, str) and bool(_TIME_RE.match(t))
def is_valid_date(d) -> bool:
if not isinstance(d, str) or not re.match(r"^\d{4}-\d{2}-\d{2}$", d):
return False
try:
datetime.date.fromisoformat(d)
return True
except ValueError:
return False
def is_valid_days(days) -> bool:
if not isinstance(days, str):
return False
days = days.strip()
if days == "*":
return True
if not days:
return False
for entry in days.split(","):
entry = entry.strip()
parts = entry.split("-", 1) if "-" in entry else [entry]
if not all(_day_to_num(p) for p in parts):
return False
return True
def validate_schedule(job: dict):
"""Prüft Zeitplanfelder. Wirft ValueError mit deutscher Meldung."""
start = job.get("schedule_start") or ""
stop = job.get("schedule_stop") or ""
date = job.get("schedule_date") or ""
end_date = job.get("schedule_end_date") or ""
days = job.get("schedule_days")
days = "*" if days is None else days
if start and not is_valid_time(start):
raise ValueError("Startzeit muss im Format HH:MM angegeben werden")
if stop and not is_valid_time(stop):
raise ValueError("Stoppzeit muss im Format HH:MM angegeben werden")
if date and not is_valid_date(date):
raise ValueError("Datum muss im Format JJJJ-MM-TT angegeben werden")
if end_date and not is_valid_date(end_date):
raise ValueError("Enddatum muss im Format JJJJ-MM-TT angegeben werden")
if not is_valid_days(days):
raise ValueError("Ungültige Wochentage (erlaubt: * oder z.B. mo-fr,sa)")
if job.get("schedule_enabled") and not start:
raise ValueError("Für einen aktiven Zeitplan ist eine Startzeit erforderlich")
if job.get("schedule_once") and not date:
raise ValueError("Für eine einmalige Aufnahme ist ein Datum erforderlich")
if end_date:
if not date:
raise ValueError("Enddatum erfordert ein Startdatum")
if end_date < date:
raise ValueError("Enddatum darf nicht vor dem Startdatum liegen")
def _time_to_minutes(t: str) -> int:
if not is_valid_time(t):
raise ValueError(f"Ungültige Uhrzeit: {t!r}")
parts = t.split(":")
return int(parts[0]) * 60 + int(parts[1])
@@ -58,40 +130,78 @@ def _apply_buffers(job: dict) -> dict:
eff = dict(job)
pre = job.get("pre_buffer", 0) or 0
post = job.get("post_buffer", 0) or 0
if pre > 0 and eff.get("schedule_start"):
if pre > 0 and is_valid_time(eff.get("schedule_start")):
m = _time_to_minutes(eff["schedule_start"]) - pre
if m < 0:
m += 1440
m %= 1440
eff["schedule_start"] = f"{m // 60:02d}:{m % 60:02d}"
if post > 0 and eff.get("schedule_stop"):
if post > 0 and is_valid_time(eff.get("schedule_stop")):
m = _time_to_minutes(eff["schedule_stop"]) + post
if m >= 1440:
m -= 1440
m %= 1440
eff["schedule_stop"] = f"{m // 60:02d}:{m % 60:02d}"
return eff
def _is_multi_day(job: dict) -> bool:
return bool(job.get("schedule_once") and job.get("schedule_date") and job.get("schedule_end_date"))
def compute_duration(job: dict, apply_buffers: bool) -> int | None:
"""Aufnahmedauer in Sekunden aus max_duration bzw. Zeitplan. Wirft ValueError bei ungültigen Daten."""
duration = job.get("max_duration")
if duration:
return int(duration)
start = job.get("schedule_start") or ""
stop = job.get("schedule_stop") or ""
if not start or not stop:
return None
pre = (job.get("pre_buffer") or 0) if apply_buffers else 0
post = (job.get("post_buffer") or 0) if apply_buffers else 0
if _is_multi_day(job):
try:
start_dt = datetime.datetime.strptime(f"{job['schedule_date']} {start}", "%Y-%m-%d %H:%M")
end_dt = datetime.datetime.strptime(f"{job['schedule_end_date']} {stop}", "%Y-%m-%d %H:%M")
except (TypeError, ValueError):
raise ValueError("Ungültiges Datum oder Uhrzeit im Zeitplan")
start_dt -= datetime.timedelta(minutes=pre)
end_dt += datetime.timedelta(minutes=post)
return max(0, int((end_dt - start_dt).total_seconds()))
eff = _apply_buffers({**job, "pre_buffer": pre, "post_buffer": post})
start_min = _time_to_minutes(eff["schedule_start"])
stop_min = _time_to_minutes(eff["schedule_stop"])
if stop_min > start_min:
return (stop_min - start_min) * 60
return (1440 - start_min + stop_min) * 60
class StreamScheduler:
def __init__(self, recorder):
self.recorder = recorder
self._scheduler = BackgroundScheduler()
self._started_keys: set[str] = set()
self._last_errors: dict[int, str] = {}
def start(self):
self._scheduler.add_job(self._tick, "interval", seconds=30, id="scheduler_tick",
replace_existing=True, max_instances=1)
self._scheduler.add_job(self.recorder.check_scheduled_stops, "interval", seconds=10,
id="check_stops", replace_existing=True, max_instances=1)
self._scheduler.add_job(self.prune_logs, "interval", hours=24,
id="prune_logs", replace_existing=True, max_instances=1)
self._scheduler.start()
def stop(self):
self._scheduler.shutdown(wait=False)
def _occurrence_key(self, job: Job) -> str:
today = datetime.date.today().isoformat()
if job.schedule_once:
today = job.schedule_date or today
return f"{job.id}|{job.schedule_once}|{today}|{job.schedule_start}"
@staticmethod
def prune_logs():
try:
days = int(os.environ.get("LOG_RETENTION_DAYS", "90"))
except ValueError:
days = 90
return prune_logs(days)
def _original_occ_key(self, job: dict, now: datetime.datetime | None = None) -> str:
now = now or datetime.datetime.now()
@@ -103,7 +213,7 @@ class StreamScheduler:
now = now or datetime.datetime.now()
today_num = now.isoweekday()
today_date = now.strftime("%Y-%m-%d")
now_time = now.strftime("%H:%M")
now_min = now.hour * 60 + now.minute
occ_key = (
f"{job['id']}|{job['schedule_once']}|"
@@ -111,58 +221,58 @@ class StreamScheduler:
f"{job['schedule_start']}"
)
is_today = False
multi_day = bool(job.get("schedule_end_date") and job["schedule_once"])
multi_day = _is_multi_day(job)
state = {
"occ_key": occ_key,
"in_window": False,
"multi_day": multi_day,
"is_cross_midnight": False,
}
start = job.get("schedule_start") or ""
stop = job.get("schedule_stop") or ""
if not is_valid_time(start):
return state
if not is_valid_time(stop):
stop = ""
days = job.get("schedule_days") or "*"
is_today = False
if job["schedule_once"]:
if job["schedule_date"] == today_date:
is_today = True
else:
if _matches_day(today_num, job["schedule_days"] or "*"):
if _matches_day(today_num, days):
is_today = True
start_min = _time_to_minutes(start)
in_window = False
is_cross_midnight = False
if is_today:
if multi_day:
now_min = _time_to_minutes(now_time)
start_min = _time_to_minutes(job["schedule_start"])
if multi_day or not stop:
in_window = now_min >= start_min
elif job["schedule_stop"]:
start_min = _time_to_minutes(job["schedule_start"])
stop_min = _time_to_minutes(job["schedule_stop"])
else:
stop_min = _time_to_minutes(stop)
if stop_min <= start_min:
is_cross_midnight = True
now_min = _time_to_minutes(now_time)
in_window = now_min >= start_min
else:
in_window = _in_time_window(now_time, job["schedule_start"], job["schedule_stop"])
else:
now_min = _time_to_minutes(now_time)
start_min = _time_to_minutes(job["schedule_start"])
in_window = now_min >= start_min
elif job.get("schedule_stop"):
start_min = _time_to_minutes(job["schedule_start"])
stop_min = _time_to_minutes(job["schedule_stop"])
if stop_min <= start_min and stop_min > 0:
now_min = _time_to_minutes(now_time)
if now_min < stop_min:
yesterday = now - datetime.timedelta(days=1)
was_yesterday = False
if job["schedule_once"]:
was_yesterday = job["schedule_date"] == yesterday.strftime("%Y-%m-%d")
else:
was_yesterday = _matches_day(yesterday.isoweekday(), job["schedule_days"] or "*")
if was_yesterday:
is_cross_midnight = True
in_window = True
in_window = start_min <= now_min < stop_min
elif stop:
stop_min = _time_to_minutes(stop)
if stop_min <= start_min and stop_min > 0 and now_min < stop_min:
yesterday = now - datetime.timedelta(days=1)
if job["schedule_once"]:
was_yesterday = job["schedule_date"] == yesterday.strftime("%Y-%m-%d")
else:
was_yesterday = _matches_day(yesterday.isoweekday(), days)
if was_yesterday:
is_cross_midnight = True
in_window = True
return {
"occ_key": occ_key,
"in_window": in_window,
"multi_day": multi_day,
"is_cross_midnight": is_cross_midnight,
}
state["in_window"] = in_window
state["is_cross_midnight"] = is_cross_midnight
return state
def suppress_current_occurrence(self, job_id: int) -> bool:
session = get_session()
@@ -184,9 +294,12 @@ class StreamScheduler:
finally:
session.close()
occ_key = self._original_occ_key(job)
eff_job = _apply_buffers(job)
state = self._schedule_state(eff_job)
try:
occ_key = self._original_occ_key(job)
state = self._schedule_state(_apply_buffers(job))
except Exception as e:
logger.warning("Zeitplan von Job %s ungültig: %s", job_id, e)
return False
if not state["in_window"]:
return False
@@ -202,9 +315,7 @@ class StreamScheduler:
)
except (TypeError, ValueError):
return None
local_utc_offset = datetime.datetime.now() - datetime.datetime.utcnow()
return local_start - local_utc_offset
return local_start.astimezone(datetime.timezone.utc).replace(tzinfo=None)
def _has_recording_for_occurrence(self, job: dict, now: datetime.datetime) -> bool:
occurrence_start = self._occurrence_start_utc(job, now)
@@ -243,14 +354,23 @@ class StreamScheduler:
).first()
if not rec:
return None
if respect_stop and rec.scheduled_stop and rec.scheduled_stop > datetime.datetime.utcnow():
if respect_stop and rec.scheduled_stop and rec.scheduled_stop > utcnow():
return None
return rec.id
finally:
session.close()
def _prune_started_keys(self, now: datetime.datetime):
cutoff = (now - datetime.timedelta(days=2)).strftime("%Y-%m-%d")
for key in list(self._started_keys):
parts = key.split("|")
date = parts[2] if len(parts) >= 4 else ""
if not is_valid_date(date) or date < cutoff:
self._started_keys.discard(key)
def _tick(self):
now = datetime.datetime.now()
self._prune_started_keys(now)
session = get_session()
try:
@@ -274,63 +394,68 @@ class StreamScheduler:
finally:
session.close()
active_ids = set()
for job in job_list:
if not job["schedule_start"]:
continue
active_ids.add(job["id"])
try:
self._process_job(job, now)
self._last_errors.pop(job["id"], None)
except Exception as e:
err = f"{type(e).__name__}: {e}"
if self._last_errors.get(job["id"]) != err:
self._last_errors[job["id"]] = err
logger.error("Zeitplan-Fehler bei Job %s (%s): %s", job["id"], job.get("name"), err)
try:
self.recorder._log("ERROR", f"Zeitplan-Fehler: {e}", job.get("name"))
except Exception:
pass
occ_key = self._original_occ_key(job, now)
eff_job = _apply_buffers(job)
state = self._schedule_state(eff_job, now)
in_window = state["in_window"]
for job_id in list(self._last_errors):
if job_id not in active_ids:
self._last_errors.pop(job_id, None)
if in_window:
if (
occ_key not in self._started_keys
and not self._is_job_recording(job["id"])
and not self._has_recording_for_occurrence(job, now)
):
duration = job["max_duration"]
if not duration and eff_job["schedule_stop"]:
if state["multi_day"]:
start_dt = datetime.datetime.strptime(
f"{job['schedule_date']} {eff_job['schedule_start']}", "%Y-%m-%d %H:%M")
end_dt = datetime.datetime.strptime(
f"{job['schedule_end_date']} {eff_job['schedule_stop']}", "%Y-%m-%d %H:%M")
duration = max(0, int((end_dt - start_dt).total_seconds()))
else:
start_min = _time_to_minutes(eff_job["schedule_start"])
stop_min = _time_to_minutes(eff_job["schedule_stop"])
if stop_min > start_min:
duration = (stop_min - start_min) * 60
else:
duration = (1440 - start_min + stop_min) * 60
def _process_job(self, job: dict, now: datetime.datetime):
if not job["schedule_start"]:
return
self.recorder.start_recording(
job_id=job["id"],
name=job["name"],
stream_url=job["stream_url"],
stream_type=job["stream_type"],
output_format=job["output_format"] or "",
max_duration=duration,
extra_ffmpeg_args=job["extra_ffmpeg_args"] or "",
segment_duration=job["segment_duration"],
is_scheduled=True,
description=job.get("description", ""),
ntfy_enabled=job.get("ntfy_enabled", False),
plik_enabled=job.get("plik_enabled", False),
delete_after_upload=job.get("delete_after_upload", False),
)
self._started_keys.add(occ_key)
else:
if (
not state["multi_day"]
and not state["is_cross_midnight"]
and eff_job["schedule_stop"]
and self._is_job_recording(job["id"])
):
rec_id = self._get_active_scheduled_recording_for_job(
job["id"], respect_stop=True,
)
if rec_id:
self.recorder.stop_recording(rec_id)
self._started_keys.discard(occ_key)
occ_key = self._original_occ_key(job, now)
eff_job = _apply_buffers(job)
state = self._schedule_state(eff_job, now)
if state["in_window"]:
if (
occ_key not in self._started_keys
and not self._is_job_recording(job["id"])
and not self._has_recording_for_occurrence(job, now)
):
duration = compute_duration(job, apply_buffers=True)
# Erst merken, damit ein Startfehler nicht alle 30s erneut versucht wird
self._started_keys.add(occ_key)
self.recorder.start_recording(
job_id=job["id"],
name=job["name"],
stream_url=job["stream_url"],
stream_type=job["stream_type"],
output_format=job["output_format"] or "",
max_duration=duration,
extra_ffmpeg_args=job["extra_ffmpeg_args"] or "",
segment_duration=job["segment_duration"],
is_scheduled=True,
description=job.get("description", ""),
ntfy_enabled=job.get("ntfy_enabled", False),
plik_enabled=job.get("plik_enabled", False),
delete_after_upload=job.get("delete_after_upload", False),
)
else:
if (
not state["multi_day"]
and not state["is_cross_midnight"]
and eff_job["schedule_stop"]
and self._is_job_recording(job["id"])
):
rec_id = self._get_active_scheduled_recording_for_job(
job["id"], respect_stop=True,
)
if rec_id:
self.recorder.stop_recording(rec_id)
self._started_keys.discard(occ_key)