import os import posixpath import secrets import shutil import tarfile import tempfile import threading import traceback from datetime import datetime from functools import wraps from pathlib import Path import zipfile from flask import Flask, flash, g, got_request_exception, jsonify, redirect, render_template, request, session, url_for import smbclient try: import py7zr except Exception: # noqa: BLE001 py7zr = None from app.config_model import AppConfig, BackupExecutionJob, BackupJob from app.config_store import ConfigStore from app.log_store import LogStore from app.remote_targets import ( TargetConnection, browse_ftp_directories, browse_sftp_directories, browse_smb_directories, create_smb_directory, list_smb_shares, normalize_subpath, test_connection, ) from app.security import hash_password, verify_password COMPRESSION_METHODS = [ "none", "zip", "tar.gz", "tar.bz2", "tar.xz", "7z", ] TARGET_PROTOCOLS = ["SMB", "LOCAL", "FTP", "SFTP"] ENCRYPTION_MODES = ["none", "password-aes256"] BACKUP_MODES = ["full", "sync", "clone"] class FlaskServer: def __init__(self, config: AppConfig, store: ConfigStore, log_store: LogStore) -> None: self.config = config self.store = store self.log_store = log_store self._run_lock = threading.Lock() self._active_runs: dict[int, dict] = {} def _log( self, *, level: str, category: str, event: str, message: str, username: str | None = None, backup_id: int | None = None, details: dict | None = None, status_code: int | None = None, ) -> None: try: request_path = request.path if request else None request_method = request.method if request else None except RuntimeError: request_path = None request_method = None resolved_user = username if not resolved_user: try: resolved_user = session.get("adminuser") except RuntimeError: resolved_user = None self.log_store.write( level=level, category=category, event=event, message=message, username=resolved_user, backup_id=backup_id, request_path=request_path, request_method=request_method, status_code=status_code, details=details, ) def _get_run_status(self, backup_id: int) -> dict: with self._run_lock: state = self._active_runs.get(backup_id) if not state: return { "is_running": False, "progress_percent": 0, "progress_text": "Bereit", "status_message": "Kein Lauf aktiv", } return { "is_running": bool(state.get("is_running", False)), "progress_percent": int(state.get("progress_percent", 0)), "progress_text": str(state.get("progress_text", "")), "status_message": str(state.get("status_message", "")), } def _set_run_state(self, backup_id: int, **values) -> None: with self._run_lock: state = self._active_runs.get(backup_id, {}) state.update(values) self._active_runs[backup_id] = state def _build_runtime_meta(self, backup_id: int) -> dict: meta = self.store.get_backup_runtime_meta(backup_id) if meta is None: return { "last_run_at": None, "last_size_bytes": None, } return meta def _start_backup_run(self, backup_id: int, trigger: str = "manual") -> tuple[bool, str]: with self._run_lock: existing = self._active_runs.get(backup_id) if existing and bool(existing.get("is_running", False)): self._log( level="WARNING", category="backup", event="run_start_rejected", backup_id=backup_id, message="Backup-Lauf konnte nicht gestartet werden, da bereits ein Lauf aktiv ist.", ) return False, "Backup-Lauf ist bereits aktiv." job = self.store.get_backup_execution_job(backup_id) if job is None: self._log( level="ERROR", category="backup", event="run_start_failed", backup_id=backup_id, message="Backup-Lauf konnte nicht gestartet werden, da das Backup nicht gefunden wurde.", ) return False, "Backup nicht gefunden." stop_event = threading.Event() thread = threading.Thread( target=self._run_backup_job, args=(job, stop_event), daemon=True, name=f"backup-run-{backup_id}", ) trigger_normalized = (trigger or "manual").strip().lower() is_scheduled = trigger_normalized == "scheduled" start_label = "Geplanter Lauf" if is_scheduled else "Manueller Lauf" self._set_run_state( backup_id, is_running=True, progress_percent=2, progress_text="Startet...", status_message=f"{start_label} wird gestartet...", stop_event=stop_event, thread=thread, ) thread.start() self._log( level="INFO", category="backup", event="run_started", backup_id=backup_id, message=f"{start_label} wurde gestartet: {job.name}", details={"trigger": trigger_normalized}, ) return True, f"{start_label} für '{job.name}' wurde gestartet." def _stop_backup_run(self, backup_id: int) -> tuple[bool, str]: with self._run_lock: state = self._active_runs.get(backup_id) if not state or not bool(state.get("is_running", False)): self._log( level="WARNING", category="backup", event="run_stop_rejected", backup_id=backup_id, message="Backup-Abbruch angefordert, aber kein Lauf war aktiv.", ) return False, "Kein aktiver Lauf zum Abbrechen." stop_event = state.get("stop_event") if stop_event is None or not hasattr(stop_event, "set"): self._log( level="ERROR", category="backup", event="run_stop_failed", backup_id=backup_id, message="Backup-Abbruch konnte nicht angefordert werden (fehlendes stop_event).", ) return False, "Abbruch konnte nicht angefordert werden." stop_event.set() state["progress_text"] = "Stoppt..." state["status_message"] = "Abbruch wurde angefordert..." self._active_runs[backup_id] = state self._log( level="INFO", category="backup", event="run_stop_requested", backup_id=backup_id, message="Abbruch eines laufenden Backups wurde angefordert.", ) return True, "Abbruch wurde angefordert." @staticmethod def _replace_target_tokens(pattern: str) -> str: now = datetime.now() value = pattern value = value.replace("{date}", now.strftime("%Y-%m-%d")) value = value.replace("{date_de}", now.strftime("%d.%m.%Y")) value = value.replace("{date_compact}", now.strftime("%Y%m%d")) value = value.replace("{time}", now.strftime("%H-%M-%S")) value = value.replace("{time_short}", now.strftime("%H%M")) value = value.replace("{datetime}", now.strftime("%Y-%m-%d_%H-%M-%S")) return value @staticmethod def _join_subpath(base: str, child: str) -> str: normalized_base = normalize_subpath(base).rstrip("/") clean_child = child.strip("/") if not clean_child: return normalize_subpath(base) if normalized_base in {"", "/"}: return f"/{clean_child}" return f"{normalized_base}/{clean_child}" @staticmethod def _smb_unc_path(host: str, share: str, subpath: str) -> str: clean_share = share.strip().strip("\\/") clean_subpath = normalize_subpath(subpath).strip("/") if clean_subpath: return f"\\\\{host}\\{clean_share}\\{clean_subpath.replace('/', '\\\\')}" return f"\\\\{host}\\{clean_share}" def _ensure_smb_dir(self, host: str, share: str, subpath: str) -> None: parts = [p for p in normalize_subpath(subpath).strip("/").split("/") if p] current = "/" for part in parts: current = self._join_subpath(current, part) unc = self._smb_unc_path(host, share, current) try: smbclient.mkdir(unc) except Exception: # noqa: BLE001 # Zielordner kann bereits existieren. pass def _collect_source_files(self, job: BackupExecutionJob) -> list[tuple[str, str]]: source_protocol = job.source_protocol.upper() files: list[tuple[str, str]] = [] if source_protocol == "LOCAL": source_path = Path(job.source_subpath).expanduser() # Auto-detect file vs directory regardless of source_entry_type if source_path.is_file(): files.append((source_path.name, str(source_path))) return files if not source_path.is_dir(): raise RuntimeError(f"Source-Pfad nicht gefunden: {source_path}") for entry in source_path.rglob("*"): if entry.is_file(): rel = entry.relative_to(source_path).as_posix() files.append((rel, str(entry))) return files if source_protocol == "SMB": smbclient.register_session( server=job.source_host, username=job.source_username, password=job.source_password, port=job.source_port or 445, ) source_subpath = normalize_subpath(job.source_subpath) if job.source_entry_type == "file": basename = posixpath.basename(source_subpath.rstrip("/")) if not basename: raise RuntimeError("SMB Source-Datei ist ungültig.") files.append((basename, source_subpath)) return files def walk(current_subpath: str, prefix: str = "") -> None: unc = self._smb_unc_path(job.source_host, job.source_share, current_subpath) for item in smbclient.scandir(unc): if item.name in {".", ".."}: continue child_subpath = self._join_subpath(current_subpath, item.name) child_rel = f"{prefix}/{item.name}" if prefix else item.name if item.is_dir(): walk(child_subpath, child_rel) else: files.append((child_rel, child_subpath)) walk(source_subpath) return files if source_protocol in {"FTP", "SFTP"}: return self._collect_source_files_ftp(job) def _collect_source_files_ftp(self, job: BackupExecutionJob) -> list[tuple[str, str]]: """Returns list of (relative_path, ftp_path) pairs from an FTP source.""" from ftplib import FTP as _FTP ftp = _FTP() files: list[tuple[str, str]] = [] try: ftp.connect(job.source_host, job.source_port or 21, timeout=30) ftp.login(job.source_username, job.source_password) subpath = normalize_subpath(job.source_subpath) if job.source_entry_type == "file": basename = posixpath.basename(subpath.rstrip("/")) or posixpath.basename(subpath) if not basename: raise RuntimeError("FTP Source-Datei ist ungültig.") files.append((basename, subpath)) return files def ftp_walk(path: str, prefix: str = "") -> None: raw: list[str] = [] try: ftp.retrlines(f"LIST {path}", raw.append) except Exception: # noqa: BLE001 return for line in raw: parts = line.split(None, 8) if len(parts) < 9: continue name = parts[8].strip() if name in (".", ".."): continue is_dir = parts[0].startswith("d") child_path = f"{path.rstrip('/')}/{name}" child_rel = f"{prefix}/{name}" if prefix else name if is_dir: ftp_walk(child_path, child_rel) else: files.append((child_rel, child_path)) ftp_walk(subpath) finally: try: ftp.quit() except Exception: # noqa: BLE001 pass return files def _build_destination_spec(self, job: BackupExecutionJob, file_path: str, destination_protocol: str) -> str: if destination_protocol == "LOCAL": return file_path if destination_protocol in {"FTP", "SFTP"}: host_port = f"{job.destination_host}:{job.destination_port}" if job.destination_port else job.destination_host return f"{host_port}|{job.destination_username}|{job.destination_password}|{file_path}" return "|".join([job.destination_host, job.destination_share, file_path]) def _build_source_spec(self, job: BackupExecutionJob, file_path: str, source_protocol: str) -> str: if source_protocol == "LOCAL": return file_path if source_protocol in {"FTP", "SFTP"}: host_port = f"{job.source_host}:{job.source_port}" if job.source_port else job.source_host return f"{host_port}|{job.source_username}|{job.source_password}|{file_path}" # SMB return "|".join([job.source_host, job.source_share, file_path]) def _copy_file_stream( self, source_protocol: str, source_location: str, destination_protocol: str, destination_location: str, stop_event: threading.Event, ) -> int: # Fast path: SMB server-side copy when source and destination are on the same host. if source_protocol == "SMB" and destination_protocol == "SMB": src_parts = source_location.split("|", 2) dst_parts = destination_location.split("|", 2) if len(src_parts) == 3 and len(dst_parts) == 3: src_host, src_share, src_subpath = src_parts dst_host, dst_share, dst_subpath = dst_parts if src_host.strip().lower() == dst_host.strip().lower(): if stop_event.is_set(): raise RuntimeError("Lauf wurde abgebrochen.") src_unc = self._smb_unc_path(src_host, src_share, src_subpath) dst_unc = self._smb_unc_path(dst_host, dst_share, dst_subpath) try: smbclient.copyfile(src_unc, dst_unc) try: return int(smbclient.stat(dst_unc).st_size or 0) except Exception: # noqa: BLE001 return int(smbclient.stat(src_unc).st_size or 0) except Exception: # noqa: BLE001 # Fallback to streamed copy for incompatible servers or share policies. pass total_bytes = 0 source_handle = None destination_handle = None cleanup_callbacks: list = [] class _StopAwareReader: def __init__(self, wrapped, stopper: threading.Event): self._wrapped = wrapped self._stopper = stopper def read(self, size: int = -1): if self._stopper.is_set(): raise RuntimeError("Lauf wurde abgebrochen.") return self._wrapped.read(size) def _parse_endpoint(endpoint: str, default_port: int) -> tuple[str, int, str, str, str]: parts = endpoint.split("|", 3) if len(parts) != 4: raise RuntimeError(f"Ungültiger Verbindungs-String: {endpoint}") host_port, user, password, remote_path = parts host, port_text = (host_port.rsplit(":", 1) + [None])[:2] port = int(port_text) if port_text else default_port return host, port, user, password, remote_path try: if source_protocol == "LOCAL": source_handle = open(source_location, "rb") elif source_protocol == "FTP": from ftplib import FTP as _FTP host, port, user, password, remote_path = _parse_endpoint(source_location, 21) ftp_dl = _FTP() spool = tempfile.SpooledTemporaryFile(max_size=32 * 1024 * 1024, mode="w+b") cleanup_callbacks.append(spool.close) try: ftp_dl.connect(host, port, timeout=60) ftp_dl.login(user, password) ftp_dl.retrbinary(f"RETR {remote_path}", spool.write, blocksize=4 * 1024 * 1024) finally: try: ftp_dl.quit() except Exception: # noqa: BLE001 pass spool.seek(0) source_handle = spool elif source_protocol == "SFTP": import paramiko host, port, user, password, remote_path = _parse_endpoint(source_location, 22) transport = paramiko.Transport((host, port)) transport.connect(username=user, password=password) sftp_client = paramiko.SFTPClient.from_transport(transport) cleanup_callbacks.append(sftp_client.close) cleanup_callbacks.append(transport.close) source_handle = sftp_client.open(remote_path, mode="rb") else: source_handle = smbclient.open_file( self._smb_unc_path(*source_location.split("|", 2)), mode="rb", ) if destination_protocol == "LOCAL": destination_handle = open(destination_location, "wb") elif destination_protocol == "FTP": from ftplib import FTP as _FTP host, port, user, password, remote_path = _parse_endpoint(destination_location, 21) ftp_dest = _FTP() try: ftp_dest.connect(host, port, timeout=60) ftp_dest.login(user, password) parent_dirs = [d for d in (remote_path.rsplit("/", 1)[0] if "/" in remote_path else "").split("/") if d] current = "" for directory in parent_dirs: current = f"{current}/{directory}" try: ftp_dest.mkd(current) except Exception: # noqa: BLE001 pass stream = _StopAwareReader(source_handle, stop_event) ftp_dest.storbinary(f"STOR {remote_path}", stream, blocksize=4 * 1024 * 1024) try: total_bytes = int(ftp_dest.size(remote_path) or 0) except Exception: # noqa: BLE001 total_bytes = 0 if total_bytes <= 0: source_handle.seek(0) while True: chunk = source_handle.read(4 * 1024 * 1024) if not chunk: break total_bytes += len(chunk) return total_bytes except Exception as exc: raise RuntimeError(f"FTP-Upload fehlgeschlagen: {exc}") from exc finally: try: ftp_dest.quit() except Exception: # noqa: BLE001 pass elif destination_protocol == "SFTP": import paramiko host, port, user, password, remote_path = _parse_endpoint(destination_location, 22) transport = paramiko.Transport((host, port)) transport.connect(username=user, password=password) sftp_client = paramiko.SFTPClient.from_transport(transport) cleanup_callbacks.append(sftp_client.close) cleanup_callbacks.append(transport.close) parent_dir = posixpath.dirname(remote_path) if parent_dir: parts = [p for p in parent_dir.split("/") if p] current = "" for part in parts: current = f"{current}/{part}" try: sftp_client.mkdir(current) except Exception: # noqa: BLE001 pass destination_handle = sftp_client.open(remote_path, mode="wb") else: destination_handle = smbclient.open_file( self._smb_unc_path(*destination_location.split("|", 2)), mode="wb", ) while True: if stop_event.is_set(): raise RuntimeError("Lauf wurde abgebrochen.") chunk = source_handle.read(4 * 1024 * 1024) if not chunk: break destination_handle.write(chunk) total_bytes += len(chunk) finally: if source_handle is not None: try: source_handle.close() except Exception: # noqa: BLE001 pass if destination_handle is not None: try: destination_handle.close() except Exception: # noqa: BLE001 pass for cb in reversed(cleanup_callbacks): try: cb() except Exception: # noqa: BLE001 pass return total_bytes @staticmethod def _compression_extension(compression_method: str) -> str: mapping = { "zip": ".zip", "tar.gz": ".tar.gz", "tar.bz2": ".tar.bz2", "tar.xz": ".tar.xz", "7z": ".7z", } return mapping.get(compression_method, "") @staticmethod def _ensure_archive_suffix(filename: str, compression_method: str) -> str: ext = FlaskServer._compression_extension(compression_method) if not ext: return filename lower_name = filename.lower() if lower_name.endswith(ext): return filename return f"{filename}{ext}" def _build_archive_file( self, job: BackupExecutionJob, source_files: list[tuple[str, str]], source_protocol: str, stop_event: threading.Event, ) -> tuple[Path, str, int]: method = job.compression_method if method not in {"zip", "tar.gz", "tar.bz2", "tar.xz", "7z"}: raise RuntimeError(f"Kompressionsmethode '{method}' wird nicht unterstützt.") if method == "7z" and py7zr is None: raise RuntimeError( "7z-Unterstützung ist nicht verfügbar. Bitte py7zr installieren und App neu starten." ) target_base = self._replace_target_tokens(job.target_pattern).strip() if not target_base: target_base = f"backup_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}" archive_name = self._ensure_archive_suffix(target_base, method) work_dir = Path(tempfile.mkdtemp(prefix="backitup-")) staging_dir = work_dir / "staging" staging_dir.mkdir(parents=True, exist_ok=True) total_count = len(source_files) for index, (relative_path, source_path) in enumerate(source_files, start=1): if stop_event.is_set(): raise RuntimeError("Lauf wurde abgebrochen.") local_target = staging_dir / relative_path local_target.parent.mkdir(parents=True, exist_ok=True) source_spec = self._build_source_spec(job, source_path, source_protocol) self._copy_file_stream( source_protocol=source_protocol, source_location=source_spec, destination_protocol="LOCAL", destination_location=str(local_target), stop_event=stop_event, ) progress = 52 + int((index / max(1, total_count)) * 18) self._set_run_state( job.backup_id, is_running=True, progress_percent=min(progress, 70), progress_text="Sammle Dateien...", status_message=f"Datei {index}/{total_count} für Archiv vorbereitet.", ) archive_path = work_dir / archive_name self._set_run_state( job.backup_id, is_running=True, progress_percent=74, progress_text="Komprimiere...", status_message=f"Erzeuge {method}-Archiv...", ) if method == "zip": with zipfile.ZipFile(archive_path, mode="w", compression=zipfile.ZIP_DEFLATED) as zf: for file_path in staging_dir.rglob("*"): if file_path.is_file(): arcname = file_path.relative_to(staging_dir).as_posix() zf.write(file_path, arcname=arcname) elif method in {"tar.gz", "tar.bz2", "tar.xz"}: mode_map = { "tar.gz": "w:gz", "tar.bz2": "w:bz2", "tar.xz": "w:xz", } with tarfile.open(archive_path, mode=mode_map[method]) as tf: for file_path in staging_dir.rglob("*"): if file_path.is_file(): arcname = file_path.relative_to(staging_dir).as_posix() tf.add(file_path, arcname=arcname) else: assert py7zr is not None with py7zr.SevenZipFile(archive_path, mode="w") as sz: for file_path in staging_dir.rglob("*"): if file_path.is_file(): arcname = file_path.relative_to(staging_dir).as_posix() sz.write(file_path, arcname=arcname) archive_bytes = archive_path.stat().st_size return archive_path, archive_name, archive_bytes def _list_destination_top_entries(self, job: BackupExecutionJob) -> list[tuple[str, float]]: """Returns [(name, mtime)] for direct children of destination_subpath.""" protocol = job.destination_protocol.upper() subpath = normalize_subpath(job.destination_subpath) if protocol == "LOCAL": result: list[tuple[str, float]] = [] root = Path(job.destination_subpath).expanduser() if not root.exists(): return result for entry in root.iterdir(): try: result.append((entry.name, entry.stat().st_mtime)) except Exception: pass return result if protocol == "SMB": result = [] unc = self._smb_unc_path(job.destination_host, job.destination_share, subpath) try: for item in smbclient.scandir(unc): if item.name in {".", ".."}: continue try: result.append((item.name, float(item.stat().st_mtime or 0))) except Exception: result.append((item.name, 0.0)) except Exception: pass return result if protocol == "FTP": from ftplib import FTP as _FTP from datetime import datetime as _dt result = [] ftp = _FTP() try: ftp.connect(job.destination_host, job.destination_port or 21, timeout=30) ftp.login(job.destination_username, job.destination_password) try: for name, facts in ftp.mlsd(subpath): if name in (".", ".."): continue mtime = 0.0 mts = facts.get("modify", "") if mts: try: mtime = _dt.strptime(mts[:14], "%Y%m%d%H%M%S").timestamp() except Exception: pass result.append((name, mtime)) except Exception: raw: list[str] = [] ftp.retrlines(f"LIST {subpath}", raw.append) for line in raw: parts = line.split(None, 8) if len(parts) >= 9 and parts[8].strip() not in (".", ".."): result.append((parts[8].strip(), 0.0)) ftp.quit() except Exception: pass return result if protocol == "SFTP": import paramiko result = [] client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) try: client.connect( job.destination_host, port=job.destination_port or 22, username=job.destination_username, password=job.destination_password, timeout=30, ) sftp = client.open_sftp() for attr in sftp.listdir_attr(subpath): if attr.filename not in (".", ".."): result.append((attr.filename, float(attr.st_mtime or 0))) sftp.close() except Exception: pass finally: client.close() return result return [] def _smb_rmdir_recursive(self, host: str, share: str, subpath: str) -> None: unc = self._smb_unc_path(host, share, subpath) try: for item in smbclient.scandir(unc): if item.name in {".", ".."}: continue child = self._join_subpath(subpath, item.name) if item.is_dir(): self._smb_rmdir_recursive(host, share, child) else: try: smbclient.remove(self._smb_unc_path(host, share, child)) except Exception: pass smbclient.rmdir(unc) except Exception: pass @staticmethod def _ftp_delete_recursive(ftp, path: str) -> None: try: ftp.delete(path) return except Exception: pass raw: list[str] = [] try: ftp.retrlines(f"LIST {path}", raw.append) except Exception: return for line in raw: parts = line.split(None, 8) if len(parts) < 9: continue name = parts[8].strip() if name in (".", ".."): continue child = f"{path.rstrip('/')}/{name}" if parts[0].startswith("d"): FlaskServer._ftp_delete_recursive(ftp, child) else: try: ftp.delete(child) except Exception: pass try: ftp.rmd(path) except Exception: pass @staticmethod def _sftp_delete_recursive(sftp, path: str) -> None: import stat as _stat try: attrs = sftp.listdir_attr(path) except Exception: try: sftp.remove(path) except Exception: pass return for attr in attrs: child = f"{path.rstrip('/')}/{attr.filename}" if _stat.S_ISDIR(attr.st_mode or 0): FlaskServer._sftp_delete_recursive(sftp, child) else: try: sftp.remove(child) except Exception: pass try: sftp.rmdir(path) except Exception: pass def _delete_destination_entry(self, job: BackupExecutionJob, entry_name: str) -> None: protocol = job.destination_protocol.upper() child_subpath = normalize_subpath(self._join_subpath(job.destination_subpath, entry_name)) try: if protocol == "LOCAL": target = Path(job.destination_subpath).expanduser() / entry_name if target.is_dir(): shutil.rmtree(target, ignore_errors=True) else: target.unlink(missing_ok=True) elif protocol == "SMB": unc = self._smb_unc_path(job.destination_host, job.destination_share, child_subpath) try: smbclient.remove(unc) except Exception: self._smb_rmdir_recursive(job.destination_host, job.destination_share, child_subpath) elif protocol == "FTP": from ftplib import FTP as _FTP ftp = _FTP() try: ftp.connect(job.destination_host, job.destination_port or 21, timeout=30) ftp.login(job.destination_username, job.destination_password) self._ftp_delete_recursive(ftp, child_subpath) ftp.quit() except Exception: pass elif protocol == "SFTP": import paramiko client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) try: client.connect( job.destination_host, port=job.destination_port or 22, username=job.destination_username, password=job.destination_password, timeout=30, ) sftp = client.open_sftp() self._sftp_delete_recursive(sftp, child_subpath) sftp.close() except Exception: pass finally: client.close() except Exception: pass def _apply_rotation(self, job: BackupExecutionJob) -> None: """Delete oldest destination entries if count exceeds rotation_keep.""" if job.rotation_keep <= 0 or job.backup_mode in {"sync", "clone"}: return entries = self._list_destination_top_entries(job) if len(entries) <= job.rotation_keep: return entries.sort(key=lambda x: x[1]) # oldest first for name, _ in entries[:len(entries) - job.rotation_keep]: self._delete_destination_entry(job, name) # ------------------------------------------------------------------ # # Sync / Clone helpers # # ------------------------------------------------------------------ # def _build_file_tree_local(self, subpath: str) -> dict[str, tuple[int, float]]: result: dict[str, tuple[int, float]] = {} root = Path(subpath).expanduser() if not root.is_dir(): return result for entry in root.rglob("*"): if entry.is_file(): st = entry.stat() result[entry.relative_to(root).as_posix()] = (st.st_size, st.st_mtime) return result def _build_file_tree_smb(self, host: str, share: str, subpath: str) -> dict[str, tuple[int, float]]: result: dict[str, tuple[int, float]] = {} def walk(current: str, prefix: str) -> None: unc = self._smb_unc_path(host, share, current) try: entries = list(smbclient.scandir(unc)) except Exception: return for item in entries: if item.name in {".", ".."}: continue child = self._join_subpath(current, item.name) rel = f"{prefix}/{item.name}" if prefix else item.name if item.is_dir(): walk(child, rel) else: try: st = item.stat() result[rel] = (st.st_size, float(st.st_mtime or 0)) except Exception: result[rel] = (0, 0.0) walk(normalize_subpath(subpath), "") return result def _build_file_tree_ftp( self, host: str, port: int | None, username: str, password: str, subpath: str ) -> dict[str, tuple[int, float]]: from ftplib import FTP as _FTP from datetime import datetime as _dt result: dict[str, tuple[int, float]] = {} ftp = _FTP() try: ftp.connect(host, port or 21, timeout=30) ftp.login(username, password) def walk(path: str, prefix: str) -> None: # Prefer MLSD (reliable size + mtime) over LIST try: entries = list(ftp.mlsd(path)) for name, facts in entries: if name in (".", ".."): continue child = f"{path.rstrip('/')}/{name}" rel = f"{prefix}/{name}" if prefix else name if facts.get("type") == "dir": walk(child, rel) else: size = int(facts.get("size", 0)) mtime = 0.0 mts = facts.get("modify", "") if mts: try: mtime = _dt.strptime(mts[:14], "%Y%m%d%H%M%S").timestamp() except Exception: pass result[rel] = (size, mtime) return except Exception: pass # Fallback: LIST raw: list[str] = [] try: ftp.retrlines(f"LIST {path}", raw.append) except Exception: return for line in raw: parts = line.split(None, 8) if len(parts) < 9: continue name = parts[8].strip() if name in (".", ".."): continue is_dir = parts[0].startswith("d") child = f"{path.rstrip('/')}/{name}" rel = f"{prefix}/{name}" if prefix else name if is_dir: walk(child, rel) else: size = int(parts[4]) if parts[4].isdigit() else 0 result[rel] = (size, 0.0) walk(normalize_subpath(subpath), "") finally: try: ftp.quit() except Exception: pass return result def _build_file_tree_sftp( self, host: str, port: int | None, username: str, password: str, subpath: str ) -> dict[str, tuple[int, float]]: import stat as _stat import paramiko result: dict[str, tuple[int, float]] = {} client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) try: client.connect(host, port=port or 22, username=username, password=password, timeout=30) sftp = client.open_sftp() def walk(path: str, prefix: str) -> None: try: attrs = sftp.listdir_attr(path) except Exception: return for attr in attrs: if attr.filename in (".", ".."): continue child = f"{path.rstrip('/')}/{attr.filename}" rel = f"{prefix}/{attr.filename}" if prefix else attr.filename if _stat.S_ISDIR(attr.st_mode or 0): walk(child, rel) else: result[rel] = (attr.st_size or 0, float(attr.st_mtime or 0)) walk(normalize_subpath(subpath), "") sftp.close() finally: client.close() return result def _build_file_tree(self, job: BackupExecutionJob, side: str) -> dict[str, tuple[int, float]]: if side == "source": protocol = job.source_protocol.upper() host, port, share = job.source_host, job.source_port, job.source_share subpath, username, password = job.source_subpath, job.source_username, job.source_password else: protocol = job.destination_protocol.upper() host, port, share = job.destination_host, job.destination_port, job.destination_share subpath, username, password = job.destination_subpath, job.destination_username, job.destination_password if protocol == "LOCAL": return self._build_file_tree_local(subpath) if protocol == "SMB": return self._build_file_tree_smb(host, share, subpath) if protocol == "FTP": return self._build_file_tree_ftp(host, port, username, password, subpath) if protocol == "SFTP": return self._build_file_tree_sftp(host, port, username, password, subpath) return {} def _delete_destination_file( self, protocol: str, job: BackupExecutionJob, rel_path: str ) -> None: subpath = self._join_subpath(job.destination_subpath, rel_path) try: if protocol == "LOCAL": Path(job.destination_subpath).expanduser().joinpath(rel_path).unlink(missing_ok=True) elif protocol == "SMB": unc = self._smb_unc_path(job.destination_host, job.destination_share, subpath) smbclient.remove(unc) elif protocol == "FTP": from ftplib import FTP as _FTP ftp = _FTP() ftp.connect(job.destination_host, job.destination_port or 21, timeout=30) ftp.login(job.destination_username, job.destination_password) ftp.delete(subpath) ftp.quit() elif protocol == "SFTP": import paramiko client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) client.connect( job.destination_host, port=job.destination_port or 22, username=job.destination_username, password=job.destination_password, timeout=30, ) client.open_sftp().remove(subpath) client.close() except Exception: pass def _sync_backup_payload(self, job: BackupExecutionJob, stop_event: threading.Event) -> tuple[int, int]: src_proto = job.source_protocol.upper() dst_proto = job.destination_protocol.upper() if src_proto == "SMB": smbclient.register_session( server=job.source_host, username=job.source_username, password=job.source_password, port=job.source_port or 445, ) if dst_proto == "SMB": smbclient.register_session( server=job.destination_host, username=job.destination_username, password=job.destination_password, port=job.destination_port or 445, ) self._set_run_state( job.backup_id, is_running=True, progress_percent=10, progress_text="Scanne Quelle...", status_message="Dateibaum der Quelle wird gelesen...", ) source_tree = self._build_file_tree(job, "source") self._set_run_state( job.backup_id, is_running=True, progress_percent=25, progress_text="Scanne Ziel...", status_message="Dateibaum des Ziels wird gelesen...", ) dest_tree = self._build_file_tree(job, "destination") # Determine changed/new files to_transfer = [ rel for rel, (src_sz, src_mt) in source_tree.items() if rel not in dest_tree or dest_tree[rel][0] != src_sz or abs(dest_tree[rel][1] - src_mt) > 2 ] # Clone only: files on destination that no longer exist on source to_delete = ( [rel for rel in dest_tree if rel not in source_tree] if job.backup_mode == "clone" else [] ) total_ops = len(to_transfer) + len(to_delete) if total_ops == 0: return 0, 0 copied_files = 0 total_bytes = 0 for index, rel_path in enumerate(to_transfer, start=1): if stop_event.is_set(): raise RuntimeError("Lauf wurde abgebrochen.") src_subpath = self._join_subpath(job.source_subpath, rel_path) dst_subpath = self._join_subpath(job.destination_subpath, rel_path) if dst_proto == "LOCAL": dst_abs = Path(job.destination_subpath).expanduser() / rel_path dst_abs.parent.mkdir(parents=True, exist_ok=True) dest_spec = str(dst_abs) elif dst_proto == "SMB": dst_parent = normalize_subpath(posixpath.dirname(dst_subpath)) self._ensure_smb_dir(job.destination_host, job.destination_share, dst_parent) dest_spec = self._build_destination_spec(job, dst_subpath, dst_proto) else: dest_spec = self._build_destination_spec(job, dst_subpath, dst_proto) src_spec = self._build_source_spec(job, src_subpath, src_proto) total_bytes += self._copy_file_stream( source_protocol=src_proto, source_location=src_spec, destination_protocol=dst_proto, destination_location=dest_spec, stop_event=stop_event, ) copied_files += 1 progress = 30 + int((index / max(1, total_ops)) * 60) self._set_run_state( job.backup_id, is_running=True, progress_percent=min(progress, 90), progress_text="Synchronisiere...", status_message=f"{index}/{len(to_transfer)} Dateien übertragen.", ) for rel_path in to_delete: if stop_event.is_set(): raise RuntimeError("Lauf wurde abgebrochen.") self._delete_destination_file(dst_proto, job, rel_path) return copied_files, total_bytes def _transfer_backup_payload(self, job: BackupExecutionJob, stop_event: threading.Event) -> tuple[int, int]: source_protocol = job.source_protocol.upper() destination_protocol = job.destination_protocol.upper() if source_protocol not in {"LOCAL", "SMB", "FTP", "SFTP"}: raise RuntimeError(f"Source-Protokoll '{source_protocol}' ist nicht unterstützt.") if destination_protocol not in {"LOCAL", "SMB", "FTP", "SFTP"}: raise RuntimeError(f"Destination-Protokoll '{destination_protocol}' ist nicht unterstützt.") if job.backup_mode in {"sync", "clone"}: return self._sync_backup_payload(job, stop_event) if destination_protocol == "SMB": smbclient.register_session( server=job.destination_host, username=job.destination_username, password=job.destination_password, port=job.destination_port or 445, ) source_files = self._collect_source_files(job) if not source_files: raise RuntimeError("Keine Dateien in der Quelle gefunden.") if job.compression_method != "none": archive_path, archive_name, archive_bytes = self._build_archive_file( job=job, source_files=source_files, source_protocol=source_protocol, stop_event=stop_event, ) destination_base = normalize_subpath(job.destination_subpath) # archive_name already encodes the target_pattern with resolved tokens + extension archive_rel = archive_name try: if destination_protocol == "LOCAL": destination_abs = (Path(job.destination_subpath).expanduser() / archive_rel).resolve() destination_abs.parent.mkdir(parents=True, exist_ok=True) destination_spec = str(destination_abs) elif destination_protocol in {"FTP", "SFTP"}: destination_subpath = self._join_subpath(destination_base, archive_rel) destination_spec = self._build_destination_spec(job, destination_subpath, destination_protocol) else: destination_subpath = self._join_subpath(destination_base, archive_rel) destination_parent = normalize_subpath(posixpath.dirname(destination_subpath)) self._ensure_smb_dir(job.destination_host, job.destination_share, destination_parent) destination_spec = "|".join([job.destination_host, job.destination_share, destination_subpath]) self._set_run_state( job.backup_id, is_running=True, progress_percent=90, progress_text="Übertrage Archiv...", status_message=f"Schreibe Archiv {archive_name} ins Ziel...", ) transferred = self._copy_file_stream( source_protocol="LOCAL", source_location=str(archive_path), destination_protocol=destination_protocol, destination_location=destination_spec, stop_event=stop_event, ) return 1, max(transferred, archive_bytes) finally: shutil.rmtree(archive_path.parent, ignore_errors=True) target_pattern = self._replace_target_tokens(job.target_pattern) destination_base = normalize_subpath(job.destination_subpath) if job.target_kind == "file" and len(source_files) != 1: raise RuntimeError("Target ist Datei, aber die Quelle enthält mehrere Dateien.") copied_files = 0 total_bytes = 0 total_count = len(source_files) for index, (relative_path, source_path) in enumerate(source_files, start=1): if stop_event.is_set(): raise RuntimeError("Lauf wurde abgebrochen.") if job.target_kind == "file": destination_rel = target_pattern else: destination_rel = f"{target_pattern}/{relative_path}" if target_pattern else relative_path if destination_protocol == "LOCAL": destination_abs = (Path(job.destination_subpath).expanduser() / destination_rel).resolve() destination_abs.parent.mkdir(parents=True, exist_ok=True) destination_spec = str(destination_abs) elif destination_protocol in {"FTP", "SFTP"}: destination_subpath = self._join_subpath(destination_base, destination_rel) destination_spec = self._build_destination_spec(job, destination_subpath, destination_protocol) else: destination_subpath = self._join_subpath(destination_base, destination_rel) destination_parent = normalize_subpath(posixpath.dirname(destination_subpath)) self._ensure_smb_dir(job.destination_host, job.destination_share, destination_parent) destination_spec = "|".join([job.destination_host, job.destination_share, destination_subpath]) source_spec = self._build_source_spec(job, source_path, source_protocol) total_bytes += self._copy_file_stream( source_protocol=source_protocol, source_location=source_spec, destination_protocol=destination_protocol, destination_location=destination_spec, stop_event=stop_event, ) copied_files += 1 progress = 50 + int((index / max(1, total_count)) * 45) self._set_run_state( job.backup_id, is_running=True, progress_percent=min(progress, 95), progress_text="Übertrage Daten...", status_message=f"Datei {index}/{total_count} wurde übertragen.", ) return copied_files, total_bytes def _run_backup_job(self, job: BackupExecutionJob, stop_event: threading.Event) -> None: backup_id = job.backup_id try: self._set_run_state( backup_id, is_running=True, progress_percent=8, progress_text="Initialisiere Lauf...", status_message="Initialisiere Lauf...", ) source = TargetConnection( protocol=job.source_protocol, host=job.source_host, port=job.source_port, share=job.source_share, subpath=job.source_subpath, username=job.source_username, password=job.source_password, ) self._set_run_state( backup_id, is_running=True, progress_percent=22, progress_text="Prüfe Quelle...", status_message="Prüfe Quelle...", ) ok, msg = test_connection(source) if not ok: self._log( level="ERROR", category="backup", event="source_check_failed", backup_id=backup_id, message=f"Quelle nicht erreichbar: {msg}", ) self._set_run_state( backup_id, is_running=False, progress_percent=0, progress_text="Fehler", status_message=f"Quelle nicht erreichbar: {msg}", ) return destination = TargetConnection( protocol=job.destination_protocol, host=job.destination_host, port=job.destination_port, share=job.destination_share, subpath=job.destination_subpath, username=job.destination_username, password=job.destination_password, ) self._set_run_state( backup_id, is_running=True, progress_percent=45, progress_text="Prüfe Ziel...", status_message="Prüfe Ziel...", ) ok, msg = test_connection(destination) if not ok: self._log( level="ERROR", category="backup", event="destination_check_failed", backup_id=backup_id, message=f"Ziel nicht erreichbar: {msg}", ) self._set_run_state( backup_id, is_running=False, progress_percent=0, progress_text="Fehler", status_message=f"Ziel nicht erreichbar: {msg}", ) return self._set_run_state( backup_id, is_running=True, progress_percent=50, progress_text="Übertrage Daten...", status_message="Datenübertragung läuft...", ) copied_files, copied_bytes = self._transfer_backup_payload(job, stop_event) if stop_event.is_set(): self._log( level="INFO", category="backup", event="run_stopped", backup_id=backup_id, message="Backup-Lauf wurde manuell gestoppt.", ) self._set_run_state( backup_id, is_running=False, progress_percent=0, progress_text="Gestoppt", status_message="Lauf wurde manuell gestoppt.", ) return self.store.mark_backup_run(backup_id, copied_bytes) self._apply_rotation(job) self._log( level="INFO", category="backup", event="run_finished", backup_id=backup_id, message=( f"Backup erfolgreich abgeschlossen: {copied_files} Dateien, {copied_bytes} Bytes." ), details={"copied_files": copied_files, "copied_bytes": copied_bytes}, ) self._set_run_state( backup_id, is_running=False, progress_percent=100, progress_text="Fertig", status_message=( f"Backup erfolgreich abgeschlossen: {copied_files} Dateien, " f"{copied_bytes} Bytes übertragen." ), ) except Exception as exc: # noqa: BLE001 self._set_run_state( backup_id, is_running=False, progress_percent=0, progress_text="Fehler", status_message=f"Lauf fehlgeschlagen: {exc}", ) self._log( level="ERROR", category="backup", event="run_failed", backup_id=backup_id, message=f"Lauf fehlgeschlagen: {exc}", details={"traceback": traceback.format_exc()}, ) @staticmethod def _parse_port(value: str) -> int | None: try: port = int(value) except ValueError: return None if 1 <= port <= 65535: return port return None @staticmethod def _parse_optional_port(value: str) -> int | None: raw = value.strip() if not raw: return None return FlaskServer._parse_port(raw) @staticmethod def _split_host_and_port(host_input: str) -> tuple[str, int | None, str | None]: raw = host_input.strip() if not raw: return "", None, "Host/IP darf nicht leer sein." # IPv6 in Klammern: [fe80::1]:445 if raw.startswith("["): end = raw.find("]") if end == -1: return "", None, "IPv6-Host muss in [ ] geklammert sein." host = raw[1:end].strip() rest = raw[end + 1 :].strip() if not rest: return host, None, None if not rest.startswith(":"): return "", None, "Nach IPv6-Host ist nur optional :Port erlaubt." port = FlaskServer._parse_port(rest[1:]) if port is None: return "", None, "Port muss zwischen 1 und 65535 liegen." return host, port, None # IPv4/FQDN optional mit :port if raw.count(":") == 1: host_part, port_part = raw.rsplit(":", 1) host_part = host_part.strip() port_part = port_part.strip() if port_part: port = FlaskServer._parse_port(port_part) if port is None: return "", None, "Port muss zwischen 1 und 65535 liegen." return host_part, port, None return raw, None, None @staticmethod def _target_from_payload(payload: dict, prefix: str) -> tuple[TargetConnection | None, str | None]: protocol = payload.get(f"{prefix}_protocol", "").strip().upper() host_input = payload.get(f"{prefix}_host", "").strip() host, parsed_port, host_error = FlaskServer._split_host_and_port(host_input) share = payload.get(f"{prefix}_share", "").strip() raw_subpath = payload.get(f"{prefix}_subpath", "") subpath = normalize_subpath(raw_subpath) username = payload.get(f"{prefix}_username", "").strip() password = payload.get(f"{prefix}_password", "") if protocol not in TARGET_PROTOCOLS: return None, "Bitte ein gueltiges Protokoll auswaehlen." if protocol != "LOCAL" and host_error: return None, host_error if protocol == "LOCAL": if not raw_subpath.strip(): return None, "Fuer LOCAL muss ein gueltiger lokaler Pfad angegeben werden." return ( TargetConnection( protocol=protocol, host="local", port=None, share="", subpath=subpath, username="", password="", ), None, ) if protocol == "SMB": if not all([host, share, username, password]): return None, "SMB benoetigt Host, Share, Benutzer und Passwort." return ( TargetConnection( protocol=protocol, host=host, port=parsed_port, share=share, subpath=subpath, username=username, password=password, ), None, ) if protocol in {"FTP", "SFTP"}: if not all([host, username, password]): return None, f"{protocol} benoetigt Host, Benutzer und Passwort." return ( TargetConnection( protocol=protocol, host=host, port=parsed_port, share=share, subpath=subpath, username=username, password=password, ), None, ) return None, "Unbekanntes Protokoll." def create_app(self) -> Flask: app = Flask(__name__, template_folder="templates") app.secret_key = os.getenv("APP_SECRET_KEY", secrets.token_hex(32)) app.config.update( SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Lax", ) @app.before_request def _capture_request_start() -> None: g._request_started_at = datetime.now() @app.after_request def _log_problematic_requests(response): status = int(response.status_code) if status >= 400: elapsed_ms = None started = getattr(g, "_request_started_at", None) if isinstance(started, datetime): elapsed_ms = int((datetime.now() - started).total_seconds() * 1000) self._log( level="WARNING" if status < 500 else "ERROR", category="http", event="request_completed", message=f"HTTP {status} für {request.method} {request.path}", details={"elapsed_ms": elapsed_ms}, status_code=status, ) return response def _handle_exception(sender, exception, **extra): self._log( level="ERROR", category="http", event="unhandled_exception", message=f"Unhandled Exception: {exception}", details={"traceback": traceback.format_exc()}, status_code=500, ) got_request_exception.connect(_handle_exception, app) @app.template_filter("fmt_bytes") def fmt_bytes(value): if value is None: return "Noch kein Lauf" try: size = float(value) except (TypeError, ValueError): return "Unbekannt" units = ["B", "KB", "MB", "GB", "TB"] unit_idx = 0 while size >= 1024 and unit_idx < len(units) - 1: size /= 1024 unit_idx += 1 return f"{size:.2f} {units[unit_idx]}" @app.template_filter("fmt_dt") def fmt_dt(value): if not value: return "Noch kein Lauf" return str(value) def parse_schedule_fields(form_data): schedule_cron = form_data.get("schedule_cron", "").strip() return {"schedule_mode": "custom", "schedule_cron": schedule_cron}, None def login_required(view_func): @wraps(view_func) def wrapped(*args, **kwargs): if session.get("is_authenticated") is not True: return redirect(url_for("login")) return view_func(*args, **kwargs) return wrapped def admin_required(view_func): @wraps(view_func) def wrapped(*args, **kwargs): if session.get("is_admin") is not True: return redirect(url_for("dashboard")) return view_func(*args, **kwargs) return wrapped @app.get("/") def index(): if session.get("is_authenticated") is True: return redirect(url_for("dashboard")) return redirect(url_for("login")) @app.route("/login", methods=["GET", "POST"]) def login(): error_message = "" if request.method == "POST": username = request.form.get("username", "") password = request.form.get("password", "") user = self.store.get_user(username) if user and verify_password(password, user.password_hash): session["is_authenticated"] = True session["is_admin"] = user.is_admin session["adminuser"] = user.username self._log( level="INFO", category="auth", event="login_success", message="Benutzer hat sich erfolgreich angemeldet.", username=user.username, ) return redirect(url_for("dashboard")) self._log( level="WARNING", category="auth", event="login_failed", message="Anmeldung fehlgeschlagen.", username=username.strip() or None, ) error_message = "Anmeldung fehlgeschlagen." return render_template("login.html", error_message=error_message) @app.get("/dashboard") @login_required def dashboard(): return render_template( "dashboard.html", adminuser=session.get("adminuser", "admin"), backups=self.store.list_backups(), active_menu="dashboard", ) @app.get("/logs") @login_required @admin_required def logs_view(): level = request.args.get("level", "").strip().upper() category = request.args.get("category", "").strip() search = request.args.get("q", "").strip() try: limit = int(request.args.get("limit", "200") or "200") except ValueError: limit = 200 entries = self.log_store.query_logs( level=level or None, category=category or None, search=search or None, limit=limit, ) level_stats = self.log_store.count_by_level_last_hours(24) categories = self.log_store.list_categories() return render_template( "logs.html", adminuser=session.get("adminuser", "admin"), logs=entries, level_stats=level_stats, categories=categories, filters={ "level": level, "category": category, "q": search, "limit": str(limit), }, active_menu="logs", detail_compactor=self.log_store.compact_details, ) @app.get("/api/logs") @login_required @admin_required def logs_api(): level = request.args.get("level", "").strip().upper() category = request.args.get("category", "").strip() search = request.args.get("q", "").strip() try: limit = int(request.args.get("limit", "200") or "200") except ValueError: limit = 200 entries = self.log_store.query_logs( level=level or None, category=category or None, search=search or None, limit=limit, ) return jsonify({"ok": True, "entries": entries, "count": len(entries)}) @app.post("/logs/clear") @login_required @admin_required def logs_clear(): removed = self.log_store.clear() self._log( level="WARNING", category="audit", event="logs_cleared", message=f"Logdaten wurden bereinigt. Geloeschte Eintraege: {removed}", ) flash(f"Logdaten wurden bereinigt ({removed} Einträge).", "success") return redirect(url_for("logs_view")) @app.post("/backups//run") @login_required @admin_required def backup_run(backup_id: int): ok, message = self._start_backup_run(backup_id) flash(message, "success" if ok else "error") return redirect(url_for("dashboard")) @app.post("/api/backups//run") @login_required @admin_required def backup_run_api(backup_id: int): ok, message = self._start_backup_run(backup_id) status_code = 200 if not ok: status_code = 404 if "nicht gefunden" in message.lower() else 409 return jsonify( { "ok": ok, "message": message, "status": self._get_run_status(backup_id), "runtime": self._build_runtime_meta(backup_id), } ), status_code @app.post("/api/backups//stop") @login_required @admin_required def backup_stop_api(backup_id: int): ok, message = self._stop_backup_run(backup_id) status_code = 200 if ok else 409 return jsonify( { "ok": ok, "message": message, "status": self._get_run_status(backup_id), "runtime": self._build_runtime_meta(backup_id), } ), status_code @app.get("/api/backups//status") @login_required @admin_required def backup_status_api(backup_id: int): return jsonify( { "ok": True, "status": self._get_run_status(backup_id), "runtime": self._build_runtime_meta(backup_id), } ) @app.route("/backups//edit", methods=["GET", "POST"]) @login_required @admin_required def backup_edit(backup_id: int): backup = self.store.get_full_backup_for_edit(backup_id) if backup is None: self._log( level="WARNING", category="backup", event="edit_not_found", backup_id=backup_id, message="Backup konnte nicht bearbeitet werden, da es nicht gefunden wurde.", ) flash("Backup nicht gefunden.", "error") return redirect(url_for("dashboard")) if request.method == "POST": payload = request.form.to_dict(flat=True) name = payload.get("name", "").strip() target_pattern = payload.get("target_pattern", "").strip() compression_method = payload.get("compression_method", "zip") encryption_mode = payload.get("encryption_mode", "none") source_entry_type = payload.get("source_entry_type", "directory").strip().lower() target_kind = payload.get("target_kind", "folder").strip().lower() # Source connection source_protocol = payload.get("source_protocol", "").strip().upper() if source_protocol == "LOCAL": source_host, source_port = "local", None else: source_host, source_port, h_err = self._split_host_and_port(payload.get("source_host", "")) if h_err: flash(f"Source: {h_err}", "error") return redirect(url_for("backup_edit", backup_id=backup_id)) source_share = payload.get("source_share", "").strip() source_subpath = normalize_subpath(payload.get("source_subpath", "/")) source_username = payload.get("source_username", "").strip() src_pwd_raw = payload.get("source_password", "") source_password: str | None = None if src_pwd_raw == "***" else src_pwd_raw # Destination connection destination_protocol = payload.get("destination_protocol", "").strip().upper() if destination_protocol == "LOCAL": destination_host, destination_port = "local", None else: destination_host, destination_port, d_err = self._split_host_and_port(payload.get("destination_host", "")) if d_err: flash(f"Destination: {d_err}", "error") return redirect(url_for("backup_edit", backup_id=backup_id)) destination_share = payload.get("destination_share", "").strip() destination_subpath = normalize_subpath(payload.get("destination_subpath", "/")) destination_username = payload.get("destination_username", "").strip() dst_pwd_raw = payload.get("destination_password", "") destination_password: str | None = None if dst_pwd_raw == "***" else dst_pwd_raw # Archive password arc_pwd_raw = payload.get("archive_password", "") arc_pwd_confirm = payload.get("archive_password_confirm", "") archive_password: str | None = None if arc_pwd_raw == "***" else arc_pwd_raw schedule, _ = parse_schedule_fields(request.form) backup_mode = payload.get("backup_mode", "full").strip().lower() try: rotation_keep = max(0, int(payload.get("rotation_keep", "0") or "0")) except ValueError: rotation_keep = 0 if not name or not target_pattern: flash("Bitte Name und Ziel ausfüllen.", "error") return redirect(url_for("backup_edit", backup_id=backup_id)) if compression_method not in COMPRESSION_METHODS: flash("Ungültige Kompressionsmethode.", "error") return redirect(url_for("backup_edit", backup_id=backup_id)) if backup_mode not in BACKUP_MODES: flash("Ungültiger Backup-Modus.", "error") return redirect(url_for("backup_edit", backup_id=backup_id)) if encryption_mode not in ENCRYPTION_MODES: flash("Ungültiger Verschlüsselungsmodus.", "error") return redirect(url_for("backup_edit", backup_id=backup_id)) if archive_password and archive_password != arc_pwd_confirm: flash("Archiv-Passwort und Bestätigung stimmen nicht überein.", "error") return redirect(url_for("backup_edit", backup_id=backup_id)) updated = self.store.update_full_backup( backup_id=backup_id, name=name, source_protocol=source_protocol, source_host=source_host, source_port=source_port, source_share=source_share, source_subpath=source_subpath, source_username=source_username, source_password=source_password, destination_protocol=destination_protocol, destination_host=destination_host, destination_port=destination_port, destination_share=destination_share, destination_subpath=destination_subpath, destination_username=destination_username, destination_password=destination_password, source_entry_type=source_entry_type, target_kind=target_kind, target_pattern=target_pattern, compression_method=compression_method, encryption_mode=encryption_mode, archive_password=archive_password, schedule_mode=schedule["schedule_mode"], schedule_cron=schedule["schedule_cron"], backup_mode=backup_mode, rotation_keep=rotation_keep, ) if not updated: self._log( level="ERROR", category="backup", event="edit_failed", backup_id=backup_id, message="Backup-Update konnte nicht gespeichert werden.", ) flash("Backup konnte nicht gespeichert werden.", "error") return redirect(url_for("backup_edit", backup_id=backup_id)) self._log( level="INFO", category="backup", event="edit_saved", backup_id=backup_id, message=f"Backup wurde aktualisiert: {name}", details={"backup_mode": backup_mode, "compression_method": compression_method}, ) flash("Backup wurde aktualisiert.", "success") return redirect(url_for("dashboard")) return render_template( "backup_edit.html", adminuser=session.get("adminuser", "admin"), backup=backup, target_protocols=TARGET_PROTOCOLS, compression_methods=COMPRESSION_METHODS, encryption_modes=ENCRYPTION_MODES, backup_modes=BACKUP_MODES, active_menu="dashboard", ) @app.post("/backups//delete") @login_required @admin_required def backup_delete(backup_id: int): deleted = self.store.delete_backup(backup_id) if not deleted: self._log( level="WARNING", category="backup", event="delete_not_found", backup_id=backup_id, message="Backup-Loeschung angefordert, aber Backup nicht gefunden.", ) flash("Backup nicht gefunden.", "error") else: self._log( level="WARNING", category="backup", event="deleted", backup_id=backup_id, message="Backup wurde geloescht.", ) flash("Backup wurde gelöscht.", "success") return redirect(url_for("dashboard")) @app.post("/api/local/mkdir") @login_required def api_local_mkdir(): import os payload = request.get_json(silent=True) or {} parent_path = payload.get("path", "").strip() folder_name = payload.get("folder_name", "").strip() if not parent_path or not folder_name: return jsonify({"ok": False, "message": "Pfad und Ordnername erforderlich."}) if "/" in folder_name or "\\" in folder_name or folder_name in (".", ".."): return jsonify({"ok": False, "message": "Ung\u00fcltiger Ordnername."}) new_path = os.path.join(os.path.abspath(parent_path), folder_name) try: os.makedirs(new_path, exist_ok=False) return jsonify({"ok": True, "path": new_path}) except FileExistsError: return jsonify({"ok": False, "message": "Ordner existiert bereits."}) except PermissionError: return jsonify({"ok": False, "message": "Kein Schreibrecht."}) except OSError as e: return jsonify({"ok": False, "message": str(e)}) @app.get("/api/local/browse") @login_required def api_local_browse(): import os raw_path = request.args.get("path", "/").strip() path = os.path.abspath(raw_path) if raw_path else "/" if not os.path.exists(path): return jsonify({"ok": False, "message": f"Pfad nicht gefunden: {path}"}) try: entries = [] parent = os.path.dirname(path) if path != "/" else "/" if path != parent: entries.append({"name": "..", "path": parent, "entry_type": "up"}) for item in sorted(os.scandir(path), key=lambda e: (not e.is_dir(), e.name.lower())): try: entries.append({ "name": item.name, "path": item.path, "entry_type": "directory" if item.is_dir() else "file", }) except (PermissionError, OSError): continue return jsonify({"ok": True, "path": path, "entries": entries}) except PermissionError: return jsonify({"ok": False, "message": "Kein Zugriff auf diesen Pfad."}) except OSError as e: return jsonify({"ok": False, "message": str(e)}) @app.post("/api/cron/preview") @login_required def api_cron_preview(): try: from croniter import croniter except ImportError: return jsonify({"ok": False, "message": "croniter nicht installiert."}), 500 payload = request.get_json(silent=True) or {} expr = payload.get("cron", "").strip() if not expr: return jsonify({"ok": False, "message": "Kein Ausdruck."}) try: it = croniter(expr, datetime.now()) next_dt = it.get_next(datetime) except Exception: return jsonify({"ok": False, "message": "Ungültiger CRON-Ausdruck."}) DE_DAYS = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"] DE_MONTHS = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"] next_run = ( f"{DE_DAYS[next_dt.weekday()]}, {next_dt.day:02d}. " f"{DE_MONTHS[next_dt.month - 1]} {next_dt.year} " f"um {next_dt.hour:02d}:{next_dt.minute:02d} Uhr" ) parts = expr.split() description = "Benutzerdefinierter Zeitplan" def _format_weekday_text(value: str) -> str | None: de_dow = ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"] short = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"] token = value.strip() if not token or token == "*": return "jeden Tag" if "/" in token: return None if "-" in token and "," not in token: try: start_raw, end_raw = token.split("-", 1) start = int(start_raw) % 7 end = int(end_raw) % 7 except ValueError: return None if start == end: return de_dow[start] return f"{short[start]}-{short[end]}" if "," in token: labels: list[str] = [] for part in token.split(","): part = part.strip() if not part: return None try: idx = int(part) % 7 except ValueError: return None labels.append(short[idx]) return ", ".join(labels) try: idx = int(token) % 7 except ValueError: return None return de_dow[idx] if len(parts) == 5: minute, hour, dom, month, dow = parts t = f"{int(hour):02d}:{int(minute):02d} Uhr" if hour.isdigit() and minute.isdigit() else "" dow_text = _format_weekday_text(dow) if all(p in ("*", "*/1") for p in parts): description = "Jede Minute" elif hour == "*" and dom == "*" and month == "*" and dow == "*" and minute.isdigit(): description = f"Jede Stunde um :{int(minute):02d} Uhr" elif dom == "*" and month == "*" and dow == "*" and t: description = f"Täglich um {t}" elif dom == "*" and month == "*" and dow_text and t and dow != "*": if "-" in dow_text or "," in dow_text: description = f"Wochentags {dow_text} um {t}" else: description = f"Wöchentlich am {dow_text} um {t}" elif month == "*" and dow == "*" and dom.isdigit() and t: description = f"Monatlich am {dom}. um {t}" return jsonify({"ok": True, "description": description, "next_run": next_run}) @app.post("/api/target/test") @login_required @admin_required def api_target_test(): payload = request.get_json(silent=True) or {} prefix = payload.get("prefix", "") if prefix not in {"source", "destination"}: return jsonify({"ok": False, "message": "Ungueltiger Bereich."}), 400 target, error = self._target_from_payload(payload, prefix) if error: return jsonify({"ok": False, "message": error}), 400 assert target is not None ok, message = test_connection(target) status = 200 if ok else 400 return jsonify({"ok": ok, "message": message}), status @app.post("/api/target/browse") @login_required @admin_required def api_target_browse(): payload = request.get_json(silent=True) or {} prefix = payload.get("prefix", "") if prefix not in {"source", "destination"}: return jsonify({"ok": False, "message": "Ungueltiger Bereich."}), 400 target, error = self._target_from_payload(payload, prefix) if error: return jsonify({"ok": False, "message": error}), 400 assert target is not None protocol = target.protocol.upper() if protocol == "SMB": if not target.share: return jsonify({"ok": False, "message": "Bitte zuerst eine SMB-Freigabe (Share) angeben."}), 400 ok, path_or_err, entries = browse_smb_directories(target) elif protocol == "FTP": ok, path_or_err, entries = browse_ftp_directories(target) elif protocol == "SFTP": ok, path_or_err, entries = browse_sftp_directories(target) else: return jsonify({"ok": False, "message": f"Browser nicht verfügbar für: {protocol}"}), 400 if not ok: return jsonify({"ok": False, "message": path_or_err}), 400 return jsonify({"ok": True, "path": path_or_err, "entries": entries}) @app.post("/api/target/browse-smb") @login_required @admin_required def api_target_browse_smb(): payload = request.get_json(silent=True) or {} prefix = payload.get("prefix", "") if prefix not in {"source", "destination"}: return jsonify({"ok": False, "message": "Ungueltiger Bereich."}), 400 target, error = self._target_from_payload(payload, prefix) if error: return jsonify({"ok": False, "message": error}), 400 if target is None or target.protocol != "SMB": return jsonify({"ok": False, "message": "SMB-Browser nur mit SMB moeglich."}), 400 if not target.share: return ( jsonify( { "ok": False, "message": "Bitte zuerst eine SMB-Freigabe (Share) waehlen oder Shares laden.", } ), 400, ) ok, path_or_error, directories = browse_smb_directories(target) if not ok: return jsonify({"ok": False, "message": path_or_error}), 400 return jsonify( { "ok": True, "path": path_or_error, "entries": directories, } ) @app.post("/api/target/smb-mkdir") @login_required @admin_required def api_target_smb_mkdir(): payload = request.get_json(silent=True) or {} prefix = payload.get("prefix", "") if prefix != "destination": return jsonify({"ok": False, "message": "Ordnererstellung ist nur fuer Destination erlaubt."}), 400 target, error = self._target_from_payload(payload, prefix) if error: return jsonify({"ok": False, "message": error}), 400 if target is None or target.protocol != "SMB": return jsonify({"ok": False, "message": "Ordnererstellung nur mit SMB moeglich."}), 400 folder_name = payload.get("folder_name", "") base_path = payload.get(f"{prefix}_subpath", "/") ok, result = create_smb_directory(target, base_path, folder_name) if not ok: return jsonify({"ok": False, "message": result}), 400 return jsonify({"ok": True, "path": result, "message": "Ordner wurde erstellt."}) @app.post("/api/target/smb-shares") @login_required @admin_required def api_target_smb_shares(): payload = request.get_json(silent=True) or {} prefix = payload.get("prefix", "") if prefix not in {"source", "destination"}: return jsonify({"ok": False, "message": "Ungueltiger Bereich."}), 400 protocol = payload.get(f"{prefix}_protocol", "").strip().upper() if protocol != "SMB": return jsonify({"ok": False, "message": "Share-Liste nur mit SMB moeglich."}), 400 host_input = payload.get(f"{prefix}_host", "").strip() host, parsed_port, host_error = self._split_host_and_port(host_input) if host_error: return jsonify({"ok": False, "message": host_error}), 400 username = payload.get(f"{prefix}_username", "").strip() password = payload.get(f"{prefix}_password", "") if not all([host, username, password]): return ( jsonify( { "ok": False, "message": "Host, Benutzer und Passwort sind zum Laden der Shares erforderlich.", } ), 400, ) target = TargetConnection( protocol="SMB", host=host, port=parsed_port, share="", subpath="/", username=username, password=password, ) ok, message, shares = list_smb_shares(target) if not ok: return jsonify({"ok": False, "message": message}), 400 return jsonify({"ok": True, "message": message, "shares": shares}) @app.route("/backups/new", methods=["GET", "POST"]) @login_required @admin_required def backup_new(): if request.method == "POST": payload = request.form.to_dict(flat=True) name = request.form.get("name", "").strip() source_target, source_error = self._target_from_payload(payload, "source") destination_target, destination_error = self._target_from_payload(payload, "destination") source_entry_type = request.form.get("source_entry_type", "directory").strip().lower() target_kind = request.form.get("target_kind", "folder").strip().lower() target_pattern = request.form.get("target_pattern", "").strip() compression_method = request.form.get("compression_method", "zip") encryption_mode = request.form.get("encryption_mode", "none") archive_password = request.form.get("archive_password", "") archive_password_confirm = request.form.get("archive_password_confirm", "") backup_mode = request.form.get("backup_mode", "full").strip().lower() try: rotation_keep = max(0, int(request.form.get("rotation_keep", "0") or "0")) except ValueError: rotation_keep = 0 schedule, schedule_error = parse_schedule_fields(request.form) if schedule_error: flash(schedule_error, "error") return redirect(url_for("backup_new")) assert schedule is not None required_values = [name, target_pattern] if any(not item for item in required_values): flash("Bitte alle Pflichtfelder ausfuellen.", "error") return redirect(url_for("backup_new")) if source_error: flash(f"Source: {source_error}", "error") return redirect(url_for("backup_new")) if destination_error: flash(f"Destination: {destination_error}", "error") return redirect(url_for("backup_new")) if compression_method not in COMPRESSION_METHODS: flash("Ungueltige Kompressionsmethode.", "error") return redirect(url_for("backup_new")) if backup_mode not in BACKUP_MODES: flash("Ungueltiger Backup-Modus.", "error") return redirect(url_for("backup_new")) if source_entry_type not in {"directory", "file"}: flash("Ungueltiger Source-Typ (Datei/Ordner).", "error") return redirect(url_for("backup_new")) if target_kind not in {"folder", "file"}: flash("Ungueltiger Target-Typ.", "error") return redirect(url_for("backup_new")) if encryption_mode not in ENCRYPTION_MODES: flash("Ungueltiger Verschluesselungsmodus.", "error") return redirect(url_for("backup_new")) if encryption_mode == "none": archive_password = "" archive_password_confirm = "" else: if not archive_password: flash("Bitte ein Archiv-Passwort setzen.", "error") return redirect(url_for("backup_new")) if archive_password != archive_password_confirm: flash("Archiv-Passwort und Bestaetigung stimmen nicht ueberein.", "error") return redirect(url_for("backup_new")) assert source_target is not None assert destination_target is not None backup = BackupJob( name=name, source_protocol=source_target.protocol, source_host=source_target.host, source_port=source_target.port, source_share=source_target.share, source_subpath=source_target.subpath, source_username=source_target.username, source_password=source_target.password, destination_protocol=destination_target.protocol, destination_host=destination_target.host, destination_port=destination_target.port, destination_share=destination_target.share, destination_subpath=destination_target.subpath, destination_username=destination_target.username, destination_password=destination_target.password, source_entry_type=source_entry_type, target_kind=target_kind, target_pattern=target_pattern, compression_method=compression_method, encryption_mode=encryption_mode, archive_password=archive_password, schedule_mode=schedule["schedule_mode"], schedule_cron=schedule["schedule_cron"], backup_mode=backup_mode, rotation_keep=rotation_keep, ) created = self.store.create_backup(backup) if not created: self._log( level="ERROR", category="backup", event="create_failed", message=f"Backup konnte nicht erstellt werden, Name existiert bereits: {name}", ) flash("Backup-Name existiert bereits.", "error") return redirect(url_for("backup_new")) self._log( level="INFO", category="backup", event="created", message=f"Backup wurde erstellt: {name}", details={ "source_protocol": source_target.protocol, "destination_protocol": destination_target.protocol, "backup_mode": backup_mode, }, ) flash("Backup wurde gespeichert.", "success") return redirect(url_for("dashboard")) return render_template( "backup_new.html", adminuser=session.get("adminuser", "admin"), compression_methods=COMPRESSION_METHODS, encryption_modes=ENCRYPTION_MODES, backup_modes=BACKUP_MODES, target_protocols=TARGET_PROTOCOLS, active_menu="backups", ) @app.route("/settings/server", methods=["GET", "POST"]) @login_required @admin_required def server_settings(): if request.method == "POST": ip = request.form.get("ip", "").strip() port = self._parse_port(request.form.get("port", "")) debug = request.form.get("debug", "false").lower() == "true" if not ip: flash("IP darf nicht leer sein.", "error") return redirect(url_for("server_settings")) if port is None: flash("Port muss zwischen 1 und 65535 liegen.", "error") return redirect(url_for("server_settings")) self.config = AppConfig(ip=ip, port=port, debug=debug) self.store.save_config(self.config) self._log( level="INFO", category="config", event="server_settings_saved", message="Server-Einstellungen wurden aktualisiert.", details={"ip": ip, "port": port, "debug": debug}, ) flash("Server-Settings gespeichert. Neustart des Services erforderlich.", "success") return redirect(url_for("server_settings")) return render_template( "server_settings.html", adminuser=session.get("adminuser", "admin"), config=self.config, active_menu="server", ) @app.route("/settings/account", methods=["GET", "POST"]) @login_required def account_settings(): current_username = session.get("adminuser", "") if request.method == "POST": new_username = request.form.get("new_username", "").strip() current_password = request.form.get("current_password", "") new_password = request.form.get("new_password", "") confirm_password = request.form.get("confirm_password", "") user = self.store.get_user(current_username) if user is None: flash("Benutzer wurde nicht gefunden.", "error") return redirect(url_for("logout")) if not verify_password(current_password, user.password_hash): flash("Aktuelles Passwort ist falsch.", "error") return redirect(url_for("account_settings")) if not new_username: flash("Neuer Benutzername darf nicht leer sein.", "error") return redirect(url_for("account_settings")) if not new_password: flash("Neues Passwort darf nicht leer sein.", "error") return redirect(url_for("account_settings")) if new_password != confirm_password: flash("Passwort-Bestaetigung stimmt nicht ueberein.", "error") return redirect(url_for("account_settings")) updated = self.store.update_user_credentials( current_username=current_username, new_username=new_username, new_password_hash=hash_password(new_password), ) if not updated: flash("Benutzername existiert bereits.", "error") return redirect(url_for("account_settings")) self._log( level="INFO", category="auth", event="account_updated", message="Account-Zugangsdaten wurden aktualisiert.", username=new_username, ) session["adminuser"] = new_username flash("Deine Zugangsdaten wurden aktualisiert.", "success") return redirect(url_for("account_settings")) return render_template( "account_settings.html", adminuser=current_username, active_menu="account", ) @app.route("/users", methods=["GET", "POST"]) @login_required @admin_required def users(): if request.method == "POST": username = request.form.get("username", "").strip() password = request.form.get("password", "") password_confirm = request.form.get("password_confirm", "") is_admin = request.form.get("is_admin") == "on" if not username: flash("Benutzername darf nicht leer sein.", "error") return redirect(url_for("users")) if not password: flash("Passwort darf nicht leer sein.", "error") return redirect(url_for("users")) if password != password_confirm: flash("Passwort-Bestaetigung stimmt nicht ueberein.", "error") return redirect(url_for("users")) created = self.store.create_user( username=username, password_hash=hash_password(password), is_admin=is_admin, ) if not created: flash("Benutzername existiert bereits.", "error") return redirect(url_for("users")) self._log( level="INFO", category="auth", event="user_created", message=f"Neuer Benutzer wurde erstellt: {username}", details={"is_admin": is_admin}, ) flash("Neuer Benutzer wurde angelegt.", "success") return redirect(url_for("users")) return render_template( "users.html", adminuser=session.get("adminuser", "admin"), users=self.store.list_users(), active_menu="users", ) @app.post("/logout") def logout(): self._log( level="INFO", category="auth", event="logout", message="Benutzer wurde abgemeldet.", ) session.clear() return redirect(url_for("login")) return app def _run_scheduler(self) -> None: from datetime import timedelta try: from croniter import croniter as CronIter except ImportError: return fired: dict[int, str] = {} # backup_id → YYYYMMDDHHmm of the scheduled slot while not self._scheduler_stop.is_set(): now = datetime.now() # Sleep until the next minute boundary sleep_secs = 61 - now.second - now.microsecond / 1_000_000 if self._scheduler_stop.wait(sleep_secs): break fire_time = datetime.now() try: backups = self.store.list_backups() except Exception as exc: self._log( level="ERROR", category="scheduler", event="list_backups_failed", message=f"Scheduler konnte Backup-Liste nicht laden: {exc}", ) continue for b in backups: if not b.schedule_cron: continue try: it = CronIter(b.schedule_cron, fire_time - timedelta(seconds=65)) next_dt = it.get_next(datetime) diff = (fire_time - next_dt).total_seconds() if not (0 <= diff <= 65): continue # Key by scheduled slot to prevent double-firing across minute boundaries sched_key = next_dt.strftime("%Y%m%d%H%M") if fired.get(b.backup_id) == sched_key: continue fired[b.backup_id] = sched_key ok, start_message = self._start_backup_run(b.backup_id, trigger="scheduled") self._log( level="INFO" if ok else "WARNING", category="scheduler", event="scheduled_run_triggered" if ok else "scheduled_run_not_started", backup_id=b.backup_id, message=( f"Geplanter Lauf ausgeloest fuer Backup: {b.name}" if ok else f"Geplanter Lauf konnte nicht gestartet werden: {b.name} ({start_message})" ), details={"scheduled_slot": sched_key, "cron": b.schedule_cron, "start_message": start_message}, ) except Exception as exc: self._log( level="ERROR", category="scheduler", event="cron_evaluation_failed", backup_id=b.backup_id, message=f"CRON-Auswertung fehlgeschlagen fuer Backup '{b.name}': {exc}", details={"cron": b.schedule_cron}, ) continue def run(self) -> None: import os app = self.create_app() # Start scheduler only in the actual serving process (not Werkzeug reloader watcher) if os.environ.get("WERKZEUG_RUN_MAIN") == "true" or not self.config.debug: self._scheduler_stop = threading.Event() threading.Thread( target=self._run_scheduler, daemon=True, name="backup-scheduler" ).start() self._log( level="INFO", category="scheduler", event="started", message="Backup-Scheduler wurde gestartet.", ) self._log( level="INFO", category="app", event="startup", message=f"Webserver startet auf {self.config.ip}:{self.config.port} (debug={self.config.debug}).", details={"ip": self.config.ip, "port": self.config.port, "debug": self.config.debug}, ) app.run(host=self.config.ip, port=self.config.port, debug=self.config.debug)