From 2eee55384e481b70b022904504a1b71f1a4c6f8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Nehlsen?= Date: Wed, 29 Jul 2026 17:36:22 +0200 Subject: [PATCH] v0.0.1 first commit --- README.md | 101 ++- app/__init__.py | 1 + app/app_runner.py | 58 ++ app/config_model.py | 93 +++ app/config_store.py | 610 +++++++++++++++++ app/crypto.py | 28 + app/dependencies.py | 53 ++ app/paths.py | 23 + app/remote_targets.py | 369 ++++++++++ app/security.py | 23 + app/setup_wizard.py | 72 ++ app/templates/account_settings.html | 31 + app/templates/backup_edit.html | 109 +++ app/templates/backup_new.html | 714 +++++++++++++++++++ app/templates/base_admin.html | 256 +++++++ app/templates/dashboard.html | 82 +++ app/templates/login.html | 157 +++++ app/templates/server_settings.html | 26 + app/templates/users.html | 56 ++ app/ui.py | 27 + app/venv_manager.py | 35 + app/web_server.py | 866 ++++++++++++++++++++++++ data/config.db | Bin 0 -> 28672 bytes data/secret.key | 1 + deploy/systemd/nextcloud-backup.service | 15 + main.py | 13 + requirements.txt | 6 + scripts/service.sh | 78 +++ 28 files changed, 3902 insertions(+), 1 deletion(-) create mode 100644 app/__init__.py create mode 100644 app/app_runner.py create mode 100644 app/config_model.py create mode 100644 app/config_store.py create mode 100644 app/crypto.py create mode 100644 app/dependencies.py create mode 100644 app/paths.py create mode 100644 app/remote_targets.py create mode 100644 app/security.py create mode 100644 app/setup_wizard.py create mode 100644 app/templates/account_settings.html create mode 100644 app/templates/backup_edit.html create mode 100644 app/templates/backup_new.html create mode 100644 app/templates/base_admin.html create mode 100644 app/templates/dashboard.html create mode 100644 app/templates/login.html create mode 100644 app/templates/server_settings.html create mode 100644 app/templates/users.html create mode 100644 app/ui.py create mode 100644 app/venv_manager.py create mode 100644 app/web_server.py create mode 100644 data/config.db create mode 100644 data/secret.key create mode 100644 deploy/systemd/nextcloud-backup.service create mode 100644 main.py create mode 100644 requirements.txt create mode 100755 scripts/service.sh diff --git a/README.md b/README.md index ee6903c..6158000 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,101 @@ -# Nextcloud_Backup +# StFV Backup + +## Modulares Python-Startsystem + +Das Projekt ist jetzt in moderne, klar getrennte Module aufgeteilt: + +- app/app_runner.py: Orchestrierung des kompletten Startablaufs +- app/venv_manager.py: .venv-Pruefung, Erstellung und Re-Exec +- app/dependencies.py: requirements-Parsing und Installation fehlender Pakete +- app/config_store.py: SQLite-Schema, Laden und Speichern der Konfiguration +- app/setup_wizard.py: Interaktive Erfassung von Erstkonfiguration +- app/security.py: Passwort-Hashing und Verifikation +- app/ui.py: Frische Konsolen-Ausgabe mit klaren Statusfarben +- app/paths.py: Zentrale Pfadverwaltung + +### Startlogik + +- Start ausserhalb von .venv: automatische Erstellung und Neustart in .venv +- requirements.txt wird geprueft, fehlende Pakete werden installiert +- SQLite unter data/config.db wird geprueft/erstellt +- Falls Konfiguration fehlt oder unvollstaendig ist, startet das Setup fuer: + - IP + - Port + - Debug (true/false) + - Adminuser + - Adminpassword + +### Bedeutung der Felder + +- IP, Port, Debug: Laufzeitkonfiguration fuer den Flask-Webserver. +- Adminuser, Adminpassword: Zugangsdaten fuer den Admin-Login im Browser. + +Sicherheitsaspekt: +- Das Passwort wird verdeckt eingegeben und ausschliesslich als PBKDF2-SHA256-Hash gespeichert. +- Eine Verifikationsfunktion fuer Login-Checks ist vorbereitet. + +### Browserzugriff + +- Nach dem Start laeuft Flask auf der konfigurierten Adresse, z. B. http://127.0.0.1:5000 +- Der Root-Pfad leitet auf /login um. +- Erfolgreiche Anmeldung fuehrt auf /dashboard. + +### Admin-Menue + +Nach dem Login stehen folgende Menuepunkte bereit: + +- Dashboard: Liste der eingerichteten Backups mit letztem Lauf und Groesse +- Neues Backup: SMB-Quelle/Ziel, Unterpfade, Target-Muster, Kompression und Verschluesselung +- Server Settings: IP, Port und Debug fuer den Flask-Webserver +- Admin Konto: Eigenen Admin-Benutzer und Passwort aendern +- Benutzer: Neue Benutzer anlegen (optional mit Adminrechten) + +### Backup-Konfiguration (aktuell) + +- Source/Destination starten mit einem Typ-Dropdown: SMB, LOCAL, FTP, SFTP +- Je nach Typ werden nur die passenden Felder eingeblendet +- Source: Verbindungs-Testbutton pro Konfiguration +- Destination: Verbindungs-Testbutton pro Konfiguration +- SMB Browser: Verzeichnisse fuer Source und Destination direkt durchklickbar +- Target: Frei definierbares Muster, z. B. backup_{date}_{time} +- Kompression: zip, tar.gz, tar.bz2, tar.xz, 7z +- Archiv-Passwort: wird verschluesselt gespeichert +- SMB-Passwoerter: werden verschluesselt gespeichert + +### Geplante Backup-Module + +Das Dashboard ist auf den Ausbau fuer mehrere Zielsysteme vorbereitet: + +- Nextcloud +- Unraid +- MS SQL (z. B. auf Win11 VM) +- Transport-Protokolle wie FTP, SFTP, SMB, NFS + +### Service fuer direkten Browser-Test + +Damit der Server dauerhaft laeuft und du direkt im Browser testen kannst: + +```bash +./scripts/service.sh install +./scripts/service.sh start +``` + +Nutzliche Befehle: + +```bash +./scripts/service.sh status +./scripts/service.sh restart +./scripts/service.sh stop +./scripts/service.sh logs +./scripts/service.sh enable +``` + +Service-Datei im Projekt: +- deploy/systemd/nextcloud-backup.service + +### Start + +```bash +python3 main.py +``` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..a8e5dcf --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +"""Modulares Startsystem fuer die Anwendung.""" diff --git a/app/app_runner.py b/app/app_runner.py new file mode 100644 index 0000000..812e687 --- /dev/null +++ b/app/app_runner.py @@ -0,0 +1,58 @@ +from pathlib import Path + +from app.dependencies import DependencyManager +from app.paths import AppPaths +from app.ui import ConsoleUI +from app.venv_manager import VirtualEnvManager + + +class AppRunner: + def __init__(self, base_dir: Path, entry_script: Path) -> None: + self.paths = AppPaths(base_dir=base_dir) + self.entry_script = entry_script + + def run(self) -> None: + ConsoleUI.headline("StFV Backup Bootstrap") + + venv = VirtualEnvManager(self.paths.venv_dir, self.entry_script) + if not venv.in_virtualenv(): + ConsoleUI.info("Virtuelle Umgebung wird vorbereitet...") + venv.ensure_and_reexec_if_needed() + + deps = DependencyManager(self.paths.requirements_path) + missing = deps.install_missing() + if missing: + ConsoleUI.success(f"Abhaengigkeiten installiert: {', '.join(missing)}") + else: + ConsoleUI.info("Alle Abhaengigkeiten sind bereits installiert.") + + from app.config_store import ConfigStore + from app.setup_wizard import SetupWizard + from app.web_server import FlaskServer + + store = ConfigStore(self.paths.db_path) + store.ensure_schema() + + config = store.load_config() + if config is None: + ConsoleUI.warn("Keine vollstaendige Konfiguration gefunden.") + setup_data = SetupWizard().collect_initial() + store.save_config(setup_data.config) + store.create_user( + setup_data.admin_username, + setup_data.admin_password_hash, + is_admin=True, + ) + config = setup_data.config + ConsoleUI.success("Konfiguration und Admin-Benutzer wurden gespeichert.") + elif not store.has_any_user(): + ConsoleUI.warn("Keine Benutzer gefunden.") + admin_username, admin_hash = SetupWizard().collect_admin_user() + store.create_user(admin_username, admin_hash, is_admin=True) + ConsoleUI.success("Admin-Benutzer wurde gespeichert.") + + ConsoleUI.success("System ist bereit.") + ConsoleUI.info( + f"Flask startet auf http://{config.ip}:{config.port} (debug={config.debug})" + ) + FlaskServer(config=config, store=store).run() diff --git a/app/config_model.py b/app/config_model.py new file mode 100644 index 0000000..468c99e --- /dev/null +++ b/app/config_model.py @@ -0,0 +1,93 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class AppConfig: + ip: str + port: int + debug: bool + + def as_db_tuple(self) -> tuple[str, int, int]: + return ( + self.ip, + self.port, + 1 if self.debug else 0, + ) + + +@dataclass(frozen=True) +class UserAccount: + username: str + password_hash: str + is_admin: bool + + +@dataclass(frozen=True) +class InitialSetupData: + config: AppConfig + admin_username: str + admin_password_hash: str + + +@dataclass(frozen=True) +class BackupJob: + name: str + source_protocol: str + source_host: str + source_port: int | None + source_share: str + source_subpath: str + source_username: str + source_password: str + destination_protocol: str + destination_host: str + destination_port: int | None + destination_share: str + destination_subpath: str + destination_username: str + destination_password: str + source_entry_type: str + target_kind: str + target_pattern: str + compression_method: str + encryption_mode: str + archive_password: str + schedule_mode: str + schedule_time: str + schedule_weekday: str + schedule_day_of_month: str + schedule_interval: int | None + schedule_cron: str + + +@dataclass(frozen=True) +class BackupExecutionJob: + backup_id: int + name: str + source_protocol: str + source_host: str + source_port: int | None + source_share: str + source_subpath: str + source_username: str + source_password: str + destination_protocol: str + destination_host: str + destination_port: int | None + destination_share: str + destination_subpath: str + destination_username: str + destination_password: str + + +@dataclass(frozen=True) +class BackupSummary: + backup_id: int + name: str + source_label: str + destination_label: str + target_pattern: str + compression_method: str + schedule_mode: str + last_run_at: str | None + last_size_bytes: int | None diff --git a/app/config_store.py b/app/config_store.py new file mode 100644 index 0000000..b131908 --- /dev/null +++ b/app/config_store.py @@ -0,0 +1,610 @@ +import sqlite3 +from pathlib import Path + +from app.config_model import AppConfig, BackupExecutionJob, BackupJob, BackupSummary, UserAccount +from app.crypto import CryptoManager + + +class ConfigStore: + def __init__(self, db_path: Path) -> None: + self.db_path = db_path + self.crypto = CryptoManager(db_path.parent / "secret.key") + + def _connect(self) -> sqlite3.Connection: + self.db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + return conn + + def ensure_schema(self) -> None: + with self._connect() as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS app_config ( + id INTEGER PRIMARY KEY CHECK (id = 1), + ip TEXT, + port INTEGER, + debug INTEGER, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + + conn.execute( + """ + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + + conn.execute( + """ + CREATE TABLE IF NOT EXISTS backups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + source_protocol TEXT NOT NULL, + source_host TEXT NOT NULL, + source_port INTEGER, + source_share TEXT NOT NULL, + source_subpath TEXT NOT NULL, + source_username TEXT NOT NULL, + source_password_enc TEXT NOT NULL, + destination_protocol TEXT NOT NULL, + destination_host TEXT NOT NULL, + destination_port INTEGER, + destination_share TEXT NOT NULL, + destination_subpath TEXT NOT NULL, + destination_username TEXT NOT NULL, + destination_password_enc TEXT NOT NULL, + source_entry_type TEXT NOT NULL DEFAULT 'directory', + target_kind TEXT NOT NULL DEFAULT 'folder', + target_pattern TEXT NOT NULL, + compression_method TEXT NOT NULL, + encryption_mode TEXT NOT NULL DEFAULT 'none', + archive_password_enc TEXT NOT NULL, + schedule_mode TEXT NOT NULL DEFAULT 'daily', + schedule_time TEXT NOT NULL DEFAULT '02:00', + schedule_weekday TEXT NOT NULL DEFAULT '1', + schedule_day_of_month TEXT NOT NULL DEFAULT '1', + schedule_interval INTEGER, + schedule_cron TEXT NOT NULL DEFAULT '', + last_run_at TEXT, + last_size_bytes INTEGER, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + + backup_columns = { + row["name"].lower() + for row in conn.execute("PRAGMA table_info(backups)").fetchall() + } + if "source_port" not in backup_columns: + conn.execute("ALTER TABLE backups ADD COLUMN source_port INTEGER") + if "destination_port" not in backup_columns: + conn.execute("ALTER TABLE backups ADD COLUMN destination_port INTEGER") + if "source_entry_type" not in backup_columns: + conn.execute("ALTER TABLE backups ADD COLUMN source_entry_type TEXT NOT NULL DEFAULT 'directory'") + if "target_kind" not in backup_columns: + conn.execute("ALTER TABLE backups ADD COLUMN target_kind TEXT NOT NULL DEFAULT 'folder'") + if "encryption_mode" not in backup_columns: + conn.execute("ALTER TABLE backups ADD COLUMN encryption_mode TEXT NOT NULL DEFAULT 'none'") + if "schedule_mode" not in backup_columns: + conn.execute("ALTER TABLE backups ADD COLUMN schedule_mode TEXT NOT NULL DEFAULT 'daily'") + if "schedule_time" not in backup_columns: + conn.execute("ALTER TABLE backups ADD COLUMN schedule_time TEXT NOT NULL DEFAULT '02:00'") + if "schedule_weekday" not in backup_columns: + conn.execute("ALTER TABLE backups ADD COLUMN schedule_weekday TEXT NOT NULL DEFAULT '1'") + if "schedule_day_of_month" not in backup_columns: + conn.execute("ALTER TABLE backups ADD COLUMN schedule_day_of_month TEXT NOT NULL DEFAULT '1'") + if "schedule_interval" not in backup_columns: + conn.execute("ALTER TABLE backups ADD COLUMN schedule_interval INTEGER") + if "schedule_cron" not in backup_columns: + conn.execute("ALTER TABLE backups ADD COLUMN schedule_cron TEXT NOT NULL DEFAULT ''") + + columns = { + row["name"].lower() + for row in conn.execute("PRAGMA table_info(app_config)").fetchall() + } + + # Legacy-Felder koennen in bestehenden Datenbanken fehlen/enthalten. + # Falls vorhanden, werden sie fuer Migration gelesen, danach nicht mehr genutzt. + if "adminuser" not in columns: + conn.execute("ALTER TABLE app_config ADD COLUMN adminuser TEXT") + if "adminpassword" not in columns: + conn.execute("ALTER TABLE app_config ADD COLUMN adminpassword TEXT") + + conn.commit() + + self._migrate_legacy_admin_if_needed() + + def _migrate_legacy_admin_if_needed(self) -> None: + with self._connect() as conn: + user_count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] + if user_count > 0: + return + + row = conn.execute( + "SELECT adminuser, adminpassword FROM app_config WHERE id = 1" + ).fetchone() + if row is None: + return + + legacy_user = row["adminuser"] + legacy_hash = row["adminpassword"] + if not isinstance(legacy_user, str) or not legacy_user.strip(): + return + if not isinstance(legacy_hash, str) or not legacy_hash.strip(): + return + + conn.execute( + """ + INSERT INTO users (username, password_hash, is_admin, updated_at) + VALUES (?, ?, 1, CURRENT_TIMESTAMP) + """, + (legacy_user.strip(), legacy_hash.strip()), + ) + conn.commit() + + def load_config(self) -> AppConfig | None: + with self._connect() as conn: + row = conn.execute( + "SELECT ip, port, debug " + "FROM app_config WHERE id = 1" + ).fetchone() + + if row is None: + return None + + ip = row["ip"] + port = row["port"] + debug = row["debug"] + + if not isinstance(ip, str) or not ip.strip(): + return None + if not isinstance(port, int): + return None + if debug not in (0, 1): + return None + + return AppConfig( + ip=ip.strip(), + port=port, + debug=bool(debug), + ) + + def save_config(self, config: AppConfig) -> None: + with self._connect() as conn: + conn.execute( + """ + INSERT INTO app_config (id, ip, port, debug, updated_at) + VALUES (1, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(id) DO UPDATE SET + ip = excluded.ip, + port = excluded.port, + debug = excluded.debug, + updated_at = CURRENT_TIMESTAMP + """, + config.as_db_tuple(), + ) + conn.commit() + + def has_any_user(self) -> bool: + with self._connect() as conn: + count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] + return count > 0 + + def create_user(self, username: str, password_hash: str, is_admin: bool = False) -> bool: + name = username.strip() + if not name: + return False + + with self._connect() as conn: + try: + conn.execute( + """ + INSERT INTO users (username, password_hash, is_admin, updated_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP) + """, + (name, password_hash, 1 if is_admin else 0), + ) + conn.commit() + return True + except sqlite3.IntegrityError: + return False + + def list_users(self) -> list[UserAccount]: + with self._connect() as conn: + rows = conn.execute( + "SELECT username, password_hash, is_admin FROM users ORDER BY username ASC" + ).fetchall() + + return [ + UserAccount( + username=row["username"], + password_hash=row["password_hash"], + is_admin=bool(row["is_admin"]), + ) + for row in rows + ] + + def get_user(self, username: str) -> UserAccount | None: + with self._connect() as conn: + row = conn.execute( + "SELECT username, password_hash, is_admin FROM users WHERE username = ?", + (username.strip(),), + ).fetchone() + + if row is None: + return None + + return UserAccount( + username=row["username"], + password_hash=row["password_hash"], + is_admin=bool(row["is_admin"]), + ) + + def update_user_credentials( + self, + current_username: str, + new_username: str, + new_password_hash: str, + ) -> bool: + with self._connect() as conn: + try: + result = conn.execute( + """ + UPDATE users + SET username = ?, password_hash = ?, updated_at = CURRENT_TIMESTAMP + WHERE username = ? + """, + (new_username.strip(), new_password_hash, current_username.strip()), + ) + conn.commit() + except sqlite3.IntegrityError: + return False + + return result.rowcount == 1 + + def create_backup(self, backup: BackupJob) -> bool: + with self._connect() as conn: + try: + conn.execute( + """ + INSERT INTO backups ( + name, + source_protocol, + source_host, + source_port, + source_share, + source_subpath, + source_username, + source_password_enc, + destination_protocol, + destination_host, + destination_port, + destination_share, + destination_subpath, + destination_username, + destination_password_enc, + source_entry_type, + target_kind, + target_pattern, + compression_method, + encryption_mode, + archive_password_enc, + schedule_mode, + schedule_time, + schedule_weekday, + schedule_day_of_month, + schedule_interval, + schedule_cron, + updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + """, + ( + backup.name, + backup.source_protocol, + backup.source_host, + backup.source_port, + backup.source_share, + backup.source_subpath, + backup.source_username, + self.crypto.encrypt_text(backup.source_password), + backup.destination_protocol, + backup.destination_host, + backup.destination_port, + backup.destination_share, + backup.destination_subpath, + backup.destination_username, + self.crypto.encrypt_text(backup.destination_password), + backup.source_entry_type, + backup.target_kind, + backup.target_pattern, + backup.compression_method, + backup.encryption_mode, + self.crypto.encrypt_text(backup.archive_password), + backup.schedule_mode, + backup.schedule_time, + backup.schedule_weekday, + backup.schedule_day_of_month, + backup.schedule_interval, + backup.schedule_cron, + ), + ) + conn.commit() + return True + except sqlite3.IntegrityError: + return False + + def list_backups(self) -> list[BackupSummary]: + with self._connect() as conn: + rows = conn.execute( + """ + SELECT + id, + name, + source_protocol, + source_host, + source_port, + source_share, + source_subpath, + destination_protocol, + destination_host, + destination_port, + destination_share, + destination_subpath, + target_pattern, + compression_method, + schedule_mode, + last_run_at, + last_size_bytes + FROM backups + ORDER BY name ASC + """ + ).fetchall() + + items: list[BackupSummary] = [] + for row in rows: + source_host = row["source_host"] or "-" + source_share = row["source_share"] or "" + source_subpath = row["source_subpath"] or "/" + destination_host = row["destination_host"] or "-" + destination_share = row["destination_share"] or "" + destination_subpath = row["destination_subpath"] or "/" + + source_port = row["source_port"] + destination_port = row["destination_port"] + source_port_suffix = f":{source_port}" if source_port else "" + destination_port_suffix = f":{destination_port}" if destination_port else "" + + source_label = ( + f"{row['source_protocol']}://{source_host}{source_port_suffix}/" + f"{source_share}{source_subpath}" + ) + destination_label = ( + f"{row['destination_protocol']}://{destination_host}{destination_port_suffix}/" + f"{destination_share}{destination_subpath}" + ) + items.append( + BackupSummary( + backup_id=row["id"], + name=row["name"], + source_label=source_label, + destination_label=destination_label, + target_pattern=row["target_pattern"], + compression_method=row["compression_method"], + schedule_mode=row["schedule_mode"] if row["schedule_mode"] else "daily", + last_run_at=row["last_run_at"], + last_size_bytes=row["last_size_bytes"], + ) + ) + + return items + + def get_backup_edit_data(self, backup_id: int) -> dict | None: + with self._connect() as conn: + row = conn.execute( + """ + SELECT + id, + name, + target_pattern, + compression_method, + encryption_mode, + schedule_mode, + schedule_time, + schedule_weekday, + schedule_day_of_month, + schedule_interval, + schedule_cron + FROM backups + WHERE id = ? + """, + (backup_id,), + ).fetchone() + + if row is None: + return None + + return { + "id": row["id"], + "name": row["name"], + "target_pattern": row["target_pattern"], + "compression_method": row["compression_method"], + "encryption_mode": row["encryption_mode"], + "schedule_mode": row["schedule_mode"], + "schedule_time": row["schedule_time"], + "schedule_weekday": row["schedule_weekday"], + "schedule_day_of_month": row["schedule_day_of_month"], + "schedule_interval": row["schedule_interval"], + "schedule_cron": row["schedule_cron"], + } + + def get_backup_execution_job(self, backup_id: int) -> BackupExecutionJob | None: + with self._connect() as conn: + row = conn.execute( + """ + SELECT + id, + name, + source_protocol, + source_host, + source_port, + source_share, + source_subpath, + source_username, + source_password_enc, + destination_protocol, + destination_host, + destination_port, + destination_share, + destination_subpath, + destination_username, + destination_password_enc + FROM backups + WHERE id = ? + """, + (backup_id,), + ).fetchone() + + if row is None: + return None + + return BackupExecutionJob( + backup_id=row["id"], + name=row["name"], + source_protocol=row["source_protocol"], + source_host=row["source_host"], + source_port=row["source_port"], + source_share=row["source_share"], + source_subpath=row["source_subpath"], + source_username=row["source_username"], + source_password=self.crypto.decrypt_text(row["source_password_enc"]), + destination_protocol=row["destination_protocol"], + destination_host=row["destination_host"], + destination_port=row["destination_port"], + destination_share=row["destination_share"], + destination_subpath=row["destination_subpath"], + destination_username=row["destination_username"], + destination_password=self.crypto.decrypt_text(row["destination_password_enc"]), + ) + + def update_backup_edit_data( + self, + backup_id: int, + name: str, + target_pattern: str, + compression_method: str, + encryption_mode: str, + archive_password: str, + schedule_mode: str, + schedule_time: str, + schedule_weekday: str, + schedule_day_of_month: str, + schedule_interval: int | None, + schedule_cron: str, + ) -> bool: + with self._connect() as conn: + try: + if archive_password: + result = conn.execute( + """ + UPDATE backups + SET + name = ?, + target_pattern = ?, + compression_method = ?, + encryption_mode = ?, + archive_password_enc = ?, + schedule_mode = ?, + schedule_time = ?, + schedule_weekday = ?, + schedule_day_of_month = ?, + schedule_interval = ?, + schedule_cron = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + ( + name, + target_pattern, + compression_method, + encryption_mode, + self.crypto.encrypt_text(archive_password), + schedule_mode, + schedule_time, + schedule_weekday, + schedule_day_of_month, + schedule_interval, + schedule_cron, + backup_id, + ), + ) + else: + result = conn.execute( + """ + UPDATE backups + SET + name = ?, + target_pattern = ?, + compression_method = ?, + encryption_mode = ?, + schedule_mode = ?, + schedule_time = ?, + schedule_weekday = ?, + schedule_day_of_month = ?, + schedule_interval = ?, + schedule_cron = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + ( + name, + target_pattern, + compression_method, + encryption_mode, + schedule_mode, + schedule_time, + schedule_weekday, + schedule_day_of_month, + schedule_interval, + schedule_cron, + backup_id, + ), + ) + conn.commit() + return result.rowcount == 1 + except sqlite3.IntegrityError: + return False + + def mark_backup_run(self, backup_id: int) -> bool: + with self._connect() as conn: + result = conn.execute( + """ + UPDATE backups + SET + last_run_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'now', 'localtime'), + last_size_bytes = COALESCE(last_size_bytes, 0), + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (backup_id,), + ) + conn.commit() + return result.rowcount == 1 + + def delete_backup(self, backup_id: int) -> bool: + with self._connect() as conn: + result = conn.execute("DELETE FROM backups WHERE id = ?", (backup_id,)) + conn.commit() + return result.rowcount == 1 diff --git a/app/crypto.py b/app/crypto.py new file mode 100644 index 0000000..24ebbef --- /dev/null +++ b/app/crypto.py @@ -0,0 +1,28 @@ +from pathlib import Path + +from cryptography.fernet import Fernet + + +class CryptoManager: + def __init__(self, key_path: Path) -> None: + self.key_path = key_path + + def _load_or_create_key(self) -> bytes: + if self.key_path.exists(): + return self.key_path.read_bytes().strip() + + self.key_path.parent.mkdir(parents=True, exist_ok=True) + key = Fernet.generate_key() + self.key_path.write_bytes(key) + return key + + def _fernet(self) -> Fernet: + return Fernet(self._load_or_create_key()) + + def encrypt_text(self, value: str) -> str: + token = self._fernet().encrypt(value.encode("utf-8")) + return token.decode("utf-8") + + def decrypt_text(self, token: str) -> str: + value = self._fernet().decrypt(token.encode("utf-8")) + return value.decode("utf-8") diff --git a/app/dependencies.py b/app/dependencies.py new file mode 100644 index 0000000..e0bca24 --- /dev/null +++ b/app/dependencies.py @@ -0,0 +1,53 @@ +import subprocess +import sys +from pathlib import Path + + +class DependencyManager: + def __init__(self, requirements_path: Path) -> None: + self.requirements_path = requirements_path + + @staticmethod + def _normalize_requirement_name(requirement_line: str) -> str: + line = requirement_line.split(";", 1)[0].strip() + for sep in ["==", ">=", "<=", "!=", "~=", ">", "<"]: + if sep in line: + line = line.split(sep, 1)[0].strip() + break + if "[" in line: + line = line.split("[", 1)[0].strip() + return line + + def parse_requirements(self) -> list[str]: + if not self.requirements_path.exists(): + return [] + + requirements: list[str] = [] + for raw in self.requirements_path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + requirements.append(line) + return requirements + + def _is_installed(self, requirement: str) -> bool: + package_name = self._normalize_requirement_name(requirement) + if not package_name: + return True + + result = subprocess.run( + [sys.executable, "-m", "pip", "show", package_name], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + return result.returncode == 0 + + def install_missing(self) -> list[str]: + requirements = self.parse_requirements() + missing = [req for req in requirements if not self._is_installed(req)] + + if missing: + subprocess.check_call([sys.executable, "-m", "pip", "install", *missing]) + + return missing diff --git a/app/paths.py b/app/paths.py new file mode 100644 index 0000000..792959e --- /dev/null +++ b/app/paths.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class AppPaths: + base_dir: Path + + @property + def venv_dir(self) -> Path: + return self.base_dir / ".venv" + + @property + def data_dir(self) -> Path: + return self.base_dir / "data" + + @property + def db_path(self) -> Path: + return self.data_dir / "config.db" + + @property + def requirements_path(self) -> Path: + return self.base_dir / "requirements.txt" diff --git a/app/remote_targets.py b/app/remote_targets.py new file mode 100644 index 0000000..88de9e1 --- /dev/null +++ b/app/remote_targets.py @@ -0,0 +1,369 @@ +from dataclasses import dataclass +from ftplib import FTP +from pathlib import Path +import shutil +import subprocess + +import paramiko +from smb.SMBConnection import SMBConnection +import smbclient + + +@dataclass(frozen=True) +class TargetConnection: + protocol: str + host: str + port: int | None + share: str + subpath: str + username: str + password: str + + +def normalize_subpath(value: str) -> str: + path = value.strip() + if not path: + return "/" + if not path.startswith("/"): + return f"/{path}" + return path + + +def _smb_list_path(path: str) -> str: + normalized = normalize_subpath(path) + trimmed = normalized.strip("/") + return "/" if not trimmed else trimmed + + +def _build_child_path(parent: str, name: str) -> str: + p = normalize_subpath(parent).rstrip("/") + if not p: + p = "/" + if p == "/": + return f"/{name}" + return f"{p}/{name}" + + +def _sort_entries(items: list[dict[str, str]]) -> list[dict[str, str]]: + return sorted(items, key=lambda x: (0 if x["entry_type"] == "directory" else 1, x["name"].lower())) + + +def _smb_unc_path(target: TargetConnection, subpath: str) -> str: + share = target.share.strip().strip("\\/") + clean_subpath = normalize_subpath(subpath).strip("/") + if clean_subpath: + rel = clean_subpath.replace("/", "\\") + return f"\\\\{target.host}\\{share}\\{rel}" + return f"\\\\{target.host}\\{share}" + + +def _list_smb_shares_with_smbprotocol(target: TargetConnection) -> tuple[bool, str, list[str]]: + try: + smbclient.register_session( + server=target.host, + username=target.username, + password=target.password, + port=target.port or 445, + ) + # Manche Server erlauben das Listing von \\host als Share-Quelle. + entries = smbclient.listdir(f"\\\\{target.host}\\") + shares = sorted({str(item).strip("\\/") for item in entries if str(item).strip("\\/")}) + if shares: + return True, "SMB-Shares erfolgreich geladen.", shares + return False, "Keine Shares gefunden.", [] + except Exception as exc: # noqa: BLE001 + return False, f"smbprotocol Fehler: {exc}", [] + + +def _list_smb_shares_with_cli(target: TargetConnection) -> tuple[bool, str, list[str]]: + smbclient_bin = shutil.which("smbclient") + if smbclient_bin is None: + return False, "smbclient CLI ist nicht installiert.", [] + + cmd = [ + smbclient_bin, + "-g", + "-L", + f"//{target.host}", + "-U", + f"{target.username}%{target.password}", + "-m", + "SMB3", + ] + if target.port: + cmd.extend(["-p", str(target.port)]) + + try: + result = subprocess.run(cmd, capture_output=True, text=True, check=False) + if result.returncode != 0: + err = (result.stderr or result.stdout or "Unbekannter Fehler").strip() + return False, f"smbclient Fehler: {err}", [] + + shares: list[str] = [] + for line in result.stdout.splitlines(): + parts = [p.strip() for p in line.split("|")] + if len(parts) < 3: + continue + if parts[0].lower() != "disk": + continue + if parts[1]: + shares.append(parts[1]) + + uniq = sorted(set(shares)) + if not uniq: + return False, "Es wurden keine Disk-Shares gefunden.", [] + return True, "SMB-Shares erfolgreich geladen.", uniq + except Exception as exc: # noqa: BLE001 + return False, f"smbclient Aufruf fehlgeschlagen: {exc}", [] + + +def list_smb_shares(target: TargetConnection) -> tuple[bool, str, list[str]]: + if target.protocol.upper() != "SMB": + return False, "Share-Liste ist nur fuer SMB verfuegbar.", [] + if not target.host or not target.username or not target.password: + return False, "Fuer Share-Liste werden Host, Benutzer und Passwort benoetigt.", [] + + ok, msg, shares = _list_smb_shares_with_smbprotocol(target) + if ok: + return True, msg, shares + + ok2, msg2, shares2 = _list_smb_shares_with_cli(target) + if ok2: + return True, msg2, shares2 + + return False, f"Share-Laden fehlgeschlagen. smbprotocol: {msg} | smbclient: {msg2}", [] + + +def _test_smb_with_smbprotocol(target: TargetConnection) -> tuple[bool, str]: + try: + smbclient.register_session( + server=target.host, + username=target.username, + password=target.password, + port=target.port or 445, + ) + unc = _smb_unc_path(target, target.subpath) + smbclient.listdir(unc) + return True, "SMB-Verbindung und Pfad sind erreichbar." + except Exception as exc: # noqa: BLE001 + return False, f"smbprotocol Fehler: {exc}" + + +def _test_smb_with_pysmb(target: TargetConnection) -> tuple[bool, str]: + conn = SMBConnection( + target.username, + target.password, + "stfv-backup-client", + "stfv-backup-server", + use_ntlm_v2=True, + is_direct_tcp=True, + ) + try: + ok = conn.connect(target.host, target.port or 445, timeout=8) + if not ok: + return False, "pysmb: SMB-Verbindung konnte nicht aufgebaut werden." + conn.listPath(target.share, _smb_list_path(target.subpath)) + return True, "SMB-Verbindung und Pfad sind erreichbar." + except Exception as exc: # noqa: BLE001 + return False, f"pysmb Fehler: {exc}" + finally: + try: + conn.close() + except Exception: # noqa: BLE001 + pass + + +def _browse_smb_with_smbprotocol(target: TargetConnection) -> tuple[bool, str, list[dict[str, str]]]: + current_path = normalize_subpath(target.subpath) + try: + smbclient.register_session( + server=target.host, + username=target.username, + password=target.password, + port=target.port or 445, + ) + unc = _smb_unc_path(target, current_path) + entries_out: list[dict[str, str]] = [] + + if current_path != "/": + parent = "/" + "/".join(current_path.strip("/").split("/")[:-1]) + parent = parent if parent else "/" + entries_out.append({"name": "..", "path": parent, "entry_type": "up"}) + + for item in smbclient.scandir(unc): + name = item.name + if name in {".", ".."}: + continue + entry_type = "directory" if item.is_dir() else "file" + entries_out.append({"name": name, "path": _build_child_path(current_path, name), "entry_type": entry_type}) + + ups = [x for x in entries_out if x["entry_type"] == "up"] + normal = [x for x in entries_out if x["entry_type"] != "up"] + return True, current_path, ups + _sort_entries(normal) + except Exception as exc: # noqa: BLE001 + return False, f"smbprotocol Fehler: {exc}", [] + + +def _browse_smb_with_pysmb(target: TargetConnection) -> tuple[bool, str, list[dict[str, str]]]: + conn = SMBConnection( + target.username, + target.password, + "stfv-backup-client", + "stfv-backup-server", + use_ntlm_v2=True, + is_direct_tcp=True, + ) + current_path = normalize_subpath(target.subpath) + + try: + ok = conn.connect(target.host, target.port or 445, timeout=8) + if not ok: + return False, "pysmb: SMB-Verbindung konnte nicht aufgebaut werden.", [] + + entries = conn.listPath(target.share, _smb_list_path(current_path)) + entries_out: list[dict[str, str]] = [] + + if current_path != "/": + parent = "/" + "/".join(current_path.strip("/").split("/")[:-1]) + parent = parent if parent else "/" + entries_out.append({"name": "..", "path": parent, "entry_type": "up"}) + + for item in entries: + if item.filename in {".", ".."}: + continue + entry_type = "directory" if item.isDirectory else "file" + entries_out.append({"name": item.filename, "path": _build_child_path(current_path, item.filename), "entry_type": entry_type}) + + ups = [x for x in entries_out if x["entry_type"] == "up"] + normal = [x for x in entries_out if x["entry_type"] != "up"] + return True, current_path, ups + _sort_entries(normal) + except Exception as exc: # noqa: BLE001 + return False, f"pysmb Fehler: {exc}", [] + finally: + try: + conn.close() + except Exception: # noqa: BLE001 + pass + + +def test_connection(target: TargetConnection) -> tuple[bool, str]: + protocol = target.protocol.upper() + + if protocol == "LOCAL": + check_path = Path(target.subpath) + if check_path.exists() and check_path.is_dir(): + return True, "Lokaler Pfad erreichbar." + return False, "Lokaler Pfad ist nicht erreichbar oder kein Verzeichnis." + + if protocol == "SMB": + if not target.share: + return False, "SMB-Share fehlt." + ok, msg = _test_smb_with_smbprotocol(target) + if ok: + return True, msg + ok2, msg2 = _test_smb_with_pysmb(target) + if ok2: + return True, msg2 + return False, f"SMB-Test fehlgeschlagen. smbprotocol: {msg} | pysmb: {msg2}" + + if protocol == "FTP": + ftp = FTP() + try: + ftp.connect(target.host, target.port or 21, timeout=8) + ftp.login(target.username, target.password) + ftp.cwd(target.subpath or "/") + return True, "FTP-Verbindung und Pfad sind erreichbar." + except Exception as exc: # noqa: BLE001 + return False, f"FTP-Test fehlgeschlagen: {exc}" + finally: + try: + ftp.quit() + except Exception: # noqa: BLE001 + pass + + if protocol == "SFTP": + transport = None + sftp = None + try: + transport = paramiko.Transport((target.host, target.port or 22)) + transport.connect(username=target.username, password=target.password) + sftp = paramiko.SFTPClient.from_transport(transport) + sftp.listdir(target.subpath or "/") + return True, "SFTP-Verbindung und Pfad sind erreichbar." + except Exception as exc: # noqa: BLE001 + return False, f"SFTP-Test fehlgeschlagen: {exc}" + finally: + if sftp is not None: + sftp.close() + if transport is not None: + transport.close() + + return False, "Unbekanntes Protokoll." + + +def browse_smb_directories(target: TargetConnection) -> tuple[bool, str, list[dict[str, str]]]: + if target.protocol.upper() != "SMB": + return False, "SMB-Browser ist nur fuer SMB verfuegbar.", [] + if not target.share: + return False, "SMB-Share fehlt.", [] + + ok, path_or_error, folders = _browse_smb_with_smbprotocol(target) + if ok: + return True, path_or_error, folders + + ok2, path_or_error2, folders2 = _browse_smb_with_pysmb(target) + if ok2: + return True, path_or_error2, folders2 + + return ( + False, + "SMB-Browsing fehlgeschlagen. " + f"smbprotocol: {path_or_error} | pysmb: {path_or_error2}", + [], + ) + + +def create_smb_directory(target: TargetConnection, base_path: str, folder_name: str) -> tuple[bool, str]: + if target.protocol.upper() != "SMB": + return False, "Neuer Ordner kann nur bei SMB erstellt werden." + clean_name = folder_name.strip().strip("/").strip("\\") + if not clean_name: + return False, "Ordnername darf nicht leer sein." + if "/" in clean_name or "\\" in clean_name: + return False, "Ordnername darf keine Pfadtrenner enthalten." + + parent = normalize_subpath(base_path) + full_path = _build_child_path(parent, clean_name) + + try: + smbclient.register_session( + server=target.host, + username=target.username, + password=target.password, + port=target.port or 445, + ) + smbclient.mkdir(_smb_unc_path(target, full_path)) + return True, full_path + except Exception as exc: # noqa: BLE001 + # Fallback via pysmb + conn = SMBConnection( + target.username, + target.password, + "stfv-backup-client", + "stfv-backup-server", + use_ntlm_v2=True, + is_direct_tcp=True, + ) + try: + ok = conn.connect(target.host, target.port or 445, timeout=8) + if not ok: + return False, f"SMB-Verbindung fehlgeschlagen: {exc}" + conn.createDirectory(target.share, _smb_list_path(full_path)) + return True, full_path + except Exception as exc2: # noqa: BLE001 + return False, f"Ordner konnte nicht erstellt werden. smbprotocol: {exc} | pysmb: {exc2}" + finally: + try: + conn.close() + except Exception: # noqa: BLE001 + pass diff --git a/app/security.py b/app/security.py new file mode 100644 index 0000000..d4d5a86 --- /dev/null +++ b/app/security.py @@ -0,0 +1,23 @@ +import hashlib +import secrets + + +def hash_password(password: str, iterations: int = 200_000) -> str: + salt = secrets.token_bytes(16) + digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations) + return f"pbkdf2_sha256${iterations}${salt.hex()}${digest.hex()}" + + +def verify_password(password: str, password_hash: str) -> bool: + try: + algorithm, rounds_raw, salt_hex, digest_hex = password_hash.split("$", 3) + if algorithm != "pbkdf2_sha256": + return False + rounds = int(rounds_raw) + salt = bytes.fromhex(salt_hex) + expected = bytes.fromhex(digest_hex) + except (ValueError, TypeError): + return False + + candidate = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, rounds) + return secrets.compare_digest(candidate, expected) diff --git a/app/setup_wizard.py b/app/setup_wizard.py new file mode 100644 index 0000000..ea3a0de --- /dev/null +++ b/app/setup_wizard.py @@ -0,0 +1,72 @@ +import getpass + +from app.config_model import AppConfig, InitialSetupData +from app.security import hash_password + + +class SetupWizard: + @staticmethod + def _ask_non_empty(prompt: str) -> str: + while True: + value = input(prompt).strip() + if value: + return value + print("Eingabe darf nicht leer sein.") + + @staticmethod + def _ask_port(prompt: str) -> int: + while True: + raw = input(prompt).strip() + try: + port = int(raw) + if 1 <= port <= 65535: + return port + except ValueError: + pass + print("Bitte eine gueltige Portnummer zwischen 1 und 65535 eingeben.") + + @staticmethod + def _ask_debug(prompt: str) -> bool: + while True: + raw = input(prompt).strip().lower() + if raw in {"true", "false"}: + return raw == "true" + print("Bitte nur true oder false eingeben.") + + @staticmethod + def _ask_password() -> str: + while True: + password = getpass.getpass("Adminpassword: ") + confirm = getpass.getpass("Adminpassword bestaetigen: ") + if not password: + print("Passwort darf nicht leer sein.") + continue + if password != confirm: + print("Passwoerter stimmen nicht ueberein.") + continue + return password + + def collect_initial(self) -> InitialSetupData: + print("\n[SETUP] Initiale Konfiguration erforderlich.") + + ip = self._ask_non_empty("IP-Adresse: ") + port = self._ask_port("Port: ") + debug = self._ask_debug("Debug (true/false): ") + adminuser = self._ask_non_empty("Adminuser: ") + password_hash = hash_password(self._ask_password()) + + return InitialSetupData( + config=AppConfig( + ip=ip, + port=port, + debug=debug, + ), + admin_username=adminuser, + admin_password_hash=password_hash, + ) + + def collect_admin_user(self) -> tuple[str, str]: + print("\n[SETUP] Es wurde kein Benutzer gefunden. Bitte Admin anlegen.") + adminuser = self._ask_non_empty("Adminuser: ") + password_hash = hash_password(self._ask_password()) + return adminuser, password_hash diff --git a/app/templates/account_settings.html b/app/templates/account_settings.html new file mode 100644 index 0000000..128caf7 --- /dev/null +++ b/app/templates/account_settings.html @@ -0,0 +1,31 @@ +{% extends "base_admin.html" %} + +{% block title %}StFV Backup - Admin Konto{% endblock %} + +{% block content %} +

Admin Konto

+

Hier kannst du deinen Benutzernamen und dein Passwort komplett aendern.

+ +
+
+ + + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+{% endblock %} diff --git a/app/templates/backup_edit.html b/app/templates/backup_edit.html new file mode 100644 index 0000000..876167a --- /dev/null +++ b/app/templates/backup_edit.html @@ -0,0 +1,109 @@ +{% extends "base_admin.html" %} + +{% block title %}StFV Backup - Backup bearbeiten{% endblock %} + +{% block content %} +

Backup bearbeiten

+

Hier kannst du Name, Ziel, Verschlüsselung und Zeitplan anpassen.

+ +
+
+ + + + + + + + + + + + +
+ + + + + +
+
+ +
+

Zeitplan

+ + + +
+ + +
+ + + + + + + + +
+ +
+ Abbrechen + +
+
+ + +{% endblock %} diff --git a/app/templates/backup_new.html b/app/templates/backup_new.html new file mode 100644 index 0000000..da25426 --- /dev/null +++ b/app/templates/backup_new.html @@ -0,0 +1,714 @@ +{% extends "base_admin.html" %} + +{% block title %}StFV Backup - Neues Backup{% endblock %} + +{% block content %} +

Neues Backup einrichten

+

Die Konfiguration erscheint Schritt fuer Schritt, damit du jedes Fenster nacheinander ausfuellen kannst.

+ + + +
+ + + +
+ Allgemein + Source + Destination + Target + Zeitplan +
+ +
+

Allgemein

+ + +
+ +
+
+ +
+

Source

+ + + +
+ + + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + + +

+ +

Ordnerauswahl setzt Source als Ordner, Dateiauswahl setzt Source als Datei.

+
+ + + +
+ + +
+
+ +
+

Destination

+ + + +
+ + + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + + +
+ + +
+

+ +
+ + + +
+ + +
+
+ +
+

Target

+ + +

Wenn in Source eine Datei gewaehlt wurde, ist dies die Zieldatei. Bei Ordnerauswahl ist dies der Zielordner.

+ + + + + + + +
+
+
+ + +
+
+ + +
+
+

Passwoerter werden verschluesselt gespeichert.

+
+ +
+ + +
+
+ +
+

Zeitplan

+ + + +
+ + +
+ + + + + + + + + +
+ + +
+
+
+ + +{% endblock %} diff --git a/app/templates/base_admin.html b/app/templates/base_admin.html new file mode 100644 index 0000000..9ac06fc --- /dev/null +++ b/app/templates/base_admin.html @@ -0,0 +1,256 @@ + + + + + + {% block title %}StFV Backup{% endblock %} + + + +
+
+
+

StFV Backup

+

Angemeldet als {{ adminuser }}

+
+
+ +
+
+ +
+
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +

{{ message }}

+ {% endfor %} +
+ {% endif %} + {% endwith %} + +
+ {% block content %}{% endblock %} +
+
+ + diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..3c28aca --- /dev/null +++ b/app/templates/dashboard.html @@ -0,0 +1,82 @@ +{% extends "base_admin.html" %} + +{% block title %}StFV Backup - Dashboard{% endblock %} + +{% block content %} + + +

Eingerichtete Backups

+

Hier siehst du alle Backup-Jobs mit Zeitplan, letztem Lauf und Größe.

+ +
+ Neues Backup einrichten +
+ +{% if backups %} + + + + + + + + + + + + {% for backup in backups %} + + + + + + + + {% endfor %} + +
NameZeitplanLetzter LaufGrößeAktionen
{{ backup.name }}{{ backup.schedule_mode }}{{ backup.last_run_at | fmt_dt }}{{ backup.last_size_bytes | fmt_bytes }} +
+ +
+ ✎ +
+ +
+
+{% else %} +
+

Noch keine Backups eingerichtet. Lege jetzt den ersten Job an.

+
+{% endif %} +{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..7abcfb8 --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,157 @@ + + + + + + StFV Backup - Login + + + +
+
+

StFV Backup

+
+
+ {% if error_message %} +

{{ error_message }}

+ {% endif %} +
+
+ + +
+
+ + +
+ +
+
+
+ + diff --git a/app/templates/server_settings.html b/app/templates/server_settings.html new file mode 100644 index 0000000..4a0a9fd --- /dev/null +++ b/app/templates/server_settings.html @@ -0,0 +1,26 @@ +{% extends "base_admin.html" %} + +{% block title %}StFV Backup - Server Settings{% endblock %} + +{% block content %} +

Server Settings

+

Diese Einstellungen gelten fuer den Flask-Webserver.

+ +
+
+ + + + + + + + + + +
+
+{% endblock %} diff --git a/app/templates/users.html b/app/templates/users.html new file mode 100644 index 0000000..ddcd3d1 --- /dev/null +++ b/app/templates/users.html @@ -0,0 +1,56 @@ +{% extends "base_admin.html" %} + +{% block title %}StFV Backup - Benutzer{% endblock %} + +{% block content %} +

Benutzerverwaltung

+

Lege neue Benutzer fuer den Browser-Login an und steuere Adminrechte.

+ +
+
+

Neuen Benutzer anlegen

+
+ + + + + + + + + + + + +
+
+ +
+

Bestehende Benutzer

+ + + + + + + + + {% for user in users %} + + + + + {% endfor %} + +
BenutzernameRolle
{{ user.username }} + {% if user.is_admin %} + Admin + {% else %} + Benutzer + {% endif %} +
+
+
+{% endblock %} diff --git a/app/ui.py b/app/ui.py new file mode 100644 index 0000000..fb55717 --- /dev/null +++ b/app/ui.py @@ -0,0 +1,27 @@ +class ConsoleUI: + RESET = "\033[0m" + BOLD = "\033[1m" + BLUE = "\033[38;5;39m" + GREEN = "\033[38;5;42m" + YELLOW = "\033[38;5;220m" + RED = "\033[38;5;196m" + + @classmethod + def headline(cls, text: str) -> None: + print(f"{cls.BOLD}{cls.BLUE}{text}{cls.RESET}") + + @classmethod + def info(cls, text: str) -> None: + print(f"{cls.BLUE}[INFO]{cls.RESET} {text}") + + @classmethod + def success(cls, text: str) -> None: + print(f"{cls.GREEN}[OK]{cls.RESET} {text}") + + @classmethod + def warn(cls, text: str) -> None: + print(f"{cls.YELLOW}[WARN]{cls.RESET} {text}") + + @classmethod + def error(cls, text: str) -> None: + print(f"{cls.RED}[ERR]{cls.RESET} {text}") diff --git a/app/venv_manager.py b/app/venv_manager.py new file mode 100644 index 0000000..a677bc1 --- /dev/null +++ b/app/venv_manager.py @@ -0,0 +1,35 @@ +import os +import subprocess +import sys +from pathlib import Path + + +class VirtualEnvManager: + def __init__(self, venv_dir: Path, entry_script: Path) -> None: + self.venv_dir = venv_dir + self.entry_script = entry_script + + @staticmethod + def in_virtualenv() -> bool: + return ( + hasattr(sys, "real_prefix") + or sys.prefix != getattr(sys, "base_prefix", sys.prefix) + ) + + def _venv_python(self) -> Path: + if os.name == "nt": + return self.venv_dir / "Scripts" / "python.exe" + return self.venv_dir / "bin" / "python" + + def ensure_and_reexec_if_needed(self) -> None: + if self.in_virtualenv(): + return + + venv_python = self._venv_python() + if not venv_python.exists(): + subprocess.check_call([sys.executable, "-m", "venv", str(self.venv_dir)]) + + os.execv( + str(venv_python), + [str(venv_python), str(self.entry_script.resolve()), *sys.argv[1:]], + ) diff --git a/app/web_server.py b/app/web_server.py new file mode 100644 index 0000000..ea4fa5e --- /dev/null +++ b/app/web_server.py @@ -0,0 +1,866 @@ +import os +import secrets +import threading +import time +from functools import wraps + +from flask import Flask, flash, jsonify, redirect, render_template, request, session, url_for + +from app.config_model import AppConfig, BackupExecutionJob, BackupJob +from app.config_store import ConfigStore +from app.remote_targets import ( + TargetConnection, + browse_smb_directories, + create_smb_directory, + list_smb_shares, + normalize_subpath, + test_connection, +) +from app.security import hash_password, verify_password + + +COMPRESSION_METHODS = [ + "zip", + "tar.gz", + "tar.bz2", + "tar.xz", + "7z", +] + +TARGET_PROTOCOLS = ["SMB", "LOCAL", "FTP", "SFTP"] +ENCRYPTION_MODES = ["none", "password-aes256"] +SCHEDULE_MODES = [ + "daily", + "weekly", + "monthly", + "yearly", + "every_n_days", + "every_n_weeks", + "custom", +] + + +class FlaskServer: + def __init__(self, config: AppConfig, store: ConfigStore) -> None: + self.config = config + self.store = store + self._run_lock = threading.Lock() + self._active_runs: dict[int, dict] = {} + + 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 _run_backup_job(self, job: BackupExecutionJob, stop_event: threading.Event) -> None: + backup_id = job.backup_id + try: + steps = [ + (8, "Initialisiere Lauf..."), + (22, "Prüfe Quelle..."), + (45, "Prüfe Ziel..."), + (70, "Bereite Übertragung vor..."), + (88, "Finalisiere..."), + ] + + for progress, text in steps: + if stop_event.is_set(): + self._set_run_state( + backup_id, + is_running=False, + progress_percent=0, + progress_text="Gestoppt", + status_message="Lauf wurde manuell gestoppt.", + ) + return + + self._set_run_state( + backup_id, + is_running=True, + progress_percent=progress, + progress_text=text, + status_message=text, + ) + + if progress == 22: + 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, + ) + ok, msg = test_connection(source) + if not ok: + self._set_run_state( + backup_id, + is_running=False, + progress_percent=0, + progress_text="Fehler", + status_message=f"Quelle nicht erreichbar: {msg}", + ) + return + + if progress == 45: + 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, + ) + ok, msg = test_connection(destination) + if not ok: + self._set_run_state( + backup_id, + is_running=False, + progress_percent=0, + progress_text="Fehler", + status_message=f"Ziel nicht erreichbar: {msg}", + ) + return + + time.sleep(0.7) + + if stop_event.is_set(): + 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) + self._set_run_state( + backup_id, + is_running=False, + progress_percent=100, + progress_text="Fertig", + status_message="Backup-Lauf erfolgreich abgeschlossen.", + ) + 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}", + ) + + @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.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_mode = form_data.get("schedule_mode", "daily").strip().lower() + schedule_time = form_data.get("schedule_time", "02:00").strip() + schedule_weekday = form_data.get("schedule_weekday", "1").strip() + schedule_day_of_month = form_data.get("schedule_day_of_month", "1").strip() + schedule_interval_raw = form_data.get("schedule_interval", "").strip() + schedule_cron = form_data.get("schedule_cron", "").strip() + + if schedule_mode not in SCHEDULE_MODES: + return None, "Ungültiger Zeitplan-Modus." + + schedule_interval = None + if schedule_mode in {"every_n_days", "every_n_weeks"}: + try: + schedule_interval = int(schedule_interval_raw) + except ValueError: + schedule_interval = None + if schedule_interval is None or schedule_interval < 1: + return None, "Intervall muss eine ganze Zahl >= 1 sein." + + if schedule_mode == "custom" and not schedule_cron: + return None, "Bei benutzerdefiniertem Zeitplan ist ein CRON-Ausdruck erforderlich." + + return { + "schedule_mode": schedule_mode, + "schedule_time": schedule_time, + "schedule_weekday": schedule_weekday, + "schedule_day_of_month": schedule_day_of_month, + "schedule_interval": schedule_interval, + "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 + return redirect(url_for("dashboard")) + 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.post("/backups//run") + @login_required + @admin_required + def backup_run(backup_id: int): + backup = self.store.get_backup_edit_data(backup_id) + if backup is None: + flash("Backup nicht gefunden.", "error") + return redirect(url_for("dashboard")) + + ok = self.store.mark_backup_run(backup_id) + if not ok: + flash("Backup nicht gefunden.", "error") + return redirect(url_for("dashboard")) + + flash( + f"Manueller Lauf für '{backup['name']}' wurde gestartet und protokolliert.", + "success", + ) + return redirect(url_for("dashboard")) + + @app.route("/backups//edit", methods=["GET", "POST"]) + @login_required + @admin_required + def backup_edit(backup_id: int): + backup = self.store.get_backup_edit_data(backup_id) + if backup is None: + flash("Backup nicht gefunden.", "error") + return redirect(url_for("dashboard")) + + if request.method == "POST": + name = request.form.get("name", "").strip() + 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", "") + + 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 encryption_mode not in ENCRYPTION_MODES: + flash("Ungültiger Verschlüsselungsmodus.", "error") + return redirect(url_for("backup_edit", backup_id=backup_id)) + + if encryption_mode == "password-aes256": + if archive_password and archive_password != archive_password_confirm: + flash("Passwort und Bestätigung stimmen nicht überein.", "error") + return redirect(url_for("backup_edit", backup_id=backup_id)) + + schedule, schedule_error = parse_schedule_fields(request.form) + if schedule_error: + flash(schedule_error, "error") + return redirect(url_for("backup_edit", backup_id=backup_id)) + assert schedule is not None + + updated = self.store.update_backup_edit_data( + backup_id=backup_id, + name=name, + target_pattern=target_pattern, + compression_method=compression_method, + encryption_mode=encryption_mode, + archive_password=archive_password, + schedule_mode=schedule["schedule_mode"], + schedule_time=schedule["schedule_time"], + schedule_weekday=schedule["schedule_weekday"], + schedule_day_of_month=schedule["schedule_day_of_month"], + schedule_interval=schedule["schedule_interval"], + schedule_cron=schedule["schedule_cron"], + ) + + if not updated: + flash("Backup konnte nicht gespeichert werden (Name eventuell doppelt).", "error") + return redirect(url_for("backup_edit", backup_id=backup_id)) + + flash("Backup wurde aktualisiert.", "success") + return redirect(url_for("dashboard")) + + return render_template( + "backup_edit.html", + adminuser=session.get("adminuser", "admin"), + backup=backup, + compression_methods=COMPRESSION_METHODS, + encryption_modes=ENCRYPTION_MODES, + schedule_modes=SCHEDULE_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: + flash("Backup nicht gefunden.", "error") + else: + flash("Backup wurde gelöscht.", "success") + return redirect(url_for("dashboard")) + + @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-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", "") + + 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 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_time=schedule["schedule_time"], + schedule_weekday=schedule["schedule_weekday"], + schedule_day_of_month=schedule["schedule_day_of_month"], + schedule_interval=schedule["schedule_interval"], + schedule_cron=schedule["schedule_cron"], + ) + + created = self.store.create_backup(backup) + if not created: + flash("Backup-Name existiert bereits.", "error") + return redirect(url_for("backup_new")) + + 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, + schedule_modes=SCHEDULE_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) + 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")) + + 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")) + + 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(): + session.clear() + return redirect(url_for("login")) + + return app + + def run(self) -> None: + app = self.create_app() + app.run(host=self.config.ip, port=self.config.port, debug=self.config.debug) diff --git a/data/config.db b/data/config.db new file mode 100644 index 0000000000000000000000000000000000000000..79b0d072f89375b63ff1a1ef3e6dae267335978a GIT binary patch literal 28672 zcmeI4+i%)d7{E=^T$h$kRmGyHimOyhvxeX+#7sydlq4*W3xTZU#q#AC+<0sEKsr|fmpo_DxILJ*NAb*;XGjqT&_JLmK7I~V(4&iPqdcM++Xc13bYmUv3| ze8f*ANf5+yye{Ci7oOssN4*2Q^$qRcxBHyfX;vRUNf7gMRpQCf%Eu?)tuS-dmD&3g zARY(+0U+?z68OzOw-8uc^Zo9*l3Yfzq+EHH)7{J`vdMTZN#^2v=_J{WBwsI+V}h=d zspDMoAekjkv#CrxTOf~;1u~w`ourO&I+^5gZhNezAyp8WOTNpI$0vBrr_*HqICYj! zju&@K&sLCV*`{kMW_eU|qDskh+y{s|CCR>J*u>-=Pqrkt^ne^Z2ibS8eqtt;e3bl5dmy@q9W*Ci2-VuFzsGl}VoG;+fO&>Yk-er+%kl*d}}R+_c@kjpT^? zM$Yua`!%p<>SkzjdsJ_{NH+mwxOPKy8`iC=-@o)tRkxAinsx)H+bt4Tx^c_1198nP ztH{Q(xW?KIt6dW-rh0GkhG`%y?I7nO${4k+-~9SQP2%sNv@^xz;}Hk72CX3dj@H2ZUyJ(1MxKnh(5r<14hgudcX*svf3|ZXM2zbZ56tzHT+d+gNk! z!?!aFfk43b$=6P^)w($Cuh|j(cGQ2~8IEtuCU%JZW}+lHQ}sT&BT8yTH+rra^t-*X zLXEG^pRG06+M1mY!~*vfinc36y1i*V5-zrG*FG@5g}}>~zE7dH8zsvU71Pl4;$UxP z#G!-uhj}$|m`ogzukj5~jHI{5lhdsUEwVM-klkqsTlZQjlD%TjUE_0Td$dJhLPl+w zUU8Wq_xb$%7(34A-zV?_0SEvAAOHk_01yBIKmZ5;0U!VbfB+DfNZ=;oTPD_KmR{U6 zSeDvpf1dq{$2U3y0P>ZpF(MwJXZY|dhH8aZ5k;mnUKV(siZB}5)hJ2jSX$wbtZF>k zeMM0mqefUkp;(zgD56plgZS1k9HUVzBVb)k<9KCP5Ga}BP(%*z$`OvmQL?Or8HQrQ zK`Iht!X(2+`AC%J$3%6W|IY~j*~ANklRy9n00AHX1b_e#00KY&2mk>f00e-*FoBzG z-y(s>0hUz|k00;m9AOHk_01yBIKmZ5;0U!VbzHkD| zGqY<;-AOzaeY1h3_UsMq_x~UNLEr@f5C8%|00;m9AOHk_01yBIKmZ7Q5d=Q6zVC_k zXB+FG)tmgYjn$WUvw_zi7g?4d{QmXTH>&}^pV&A?byq2yo_e0yqp2O5-l3VDaM7wTWiJn@CTH{4mOxpo za)hVVJYmGz<>_ALfDf Y00e*l5C8%|00>M+U}biFsXwOjFS$4QmjD0& literal 0 HcmV?d00001 diff --git a/data/secret.key b/data/secret.key new file mode 100644 index 0000000..975df6e --- /dev/null +++ b/data/secret.key @@ -0,0 +1 @@ +IOmBIoG0lF8vAwVjd81SCyjYU3kMfhl9SfCKwf9qOh4= \ No newline at end of file diff --git a/deploy/systemd/nextcloud-backup.service b/deploy/systemd/nextcloud-backup.service new file mode 100644 index 0000000..ea73c63 --- /dev/null +++ b/deploy/systemd/nextcloud-backup.service @@ -0,0 +1,15 @@ +[Unit] +Description=StFV Backup Flask Service +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/home/osadmin/Nextcloud_Backup +ExecStart=/home/osadmin/Nextcloud_Backup/.venv/bin/python /home/osadmin/Nextcloud_Backup/main.py +Restart=on-failure +RestartSec=3 +Environment=PYTHONUNBUFFERED=1 + +[Install] +WantedBy=default.target diff --git a/main.py b/main.py new file mode 100644 index 0000000..cf35953 --- /dev/null +++ b/main.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +from pathlib import Path + +from app.app_runner import AppRunner + + +def main() -> None: + base_dir = Path(__file__).resolve().parent + AppRunner(base_dir=base_dir, entry_script=Path(__file__)).run() + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0c61df0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +# Projektabhaengigkeiten +Flask>=3.0,<4.0 +cryptography>=43.0,<44.0 +pysmb>=1.2.10,<2.0 +paramiko>=3.4,<4.0 +smbprotocol>=1.13.0,<2.0 diff --git a/scripts/service.sh b/scripts/service.sh new file mode 100755 index 0000000..712fe46 --- /dev/null +++ b/scripts/service.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +SERVICE_NAME="nextcloud-backup.service" +PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +USER_SYSTEMD_DIR="${HOME}/.config/systemd/user" +TARGET_SERVICE_FILE="${USER_SYSTEMD_DIR}/${SERVICE_NAME}" +SOURCE_SERVICE_FILE="${PROJECT_DIR}/deploy/systemd/${SERVICE_NAME}" + +usage() { + cat < + +Commands: + install Install service for current user and reload daemon + start Start service + stop Stop service + restart Restart service + status Show service status + logs Follow service logs + enable Enable autostart on login + disable Disable autostart on login +EOF +} + +ensure_venv() { + if [[ ! -x "${PROJECT_DIR}/.venv/bin/python" ]]; then + echo "[INFO] Erzeuge .venv..." + python3 -m venv "${PROJECT_DIR}/.venv" + fi + + echo "[INFO] Installiere/aktualisiere Abhaengigkeiten..." + "${PROJECT_DIR}/.venv/bin/python" -m pip install -r "${PROJECT_DIR}/requirements.txt" +} + +install_service() { + mkdir -p "${USER_SYSTEMD_DIR}" + cp "${SOURCE_SERVICE_FILE}" "${TARGET_SERVICE_FILE}" + systemctl --user daemon-reload + echo "[OK] Service installiert: ${TARGET_SERVICE_FILE}" +} + +case "${1:-}" in + install) + ensure_venv + install_service + ;; + start) + systemctl --user start "${SERVICE_NAME}" + echo "[OK] Service gestartet" + ;; + stop) + systemctl --user stop "${SERVICE_NAME}" + echo "[OK] Service gestoppt" + ;; + restart) + systemctl --user restart "${SERVICE_NAME}" + echo "[OK] Service neugestartet" + ;; + status) + systemctl --user status "${SERVICE_NAME}" --no-pager + ;; + logs) + journalctl --user -u "${SERVICE_NAME}" -f + ;; + enable) + systemctl --user enable "${SERVICE_NAME}" + echo "[OK] Autostart aktiviert" + ;; + disable) + systemctl --user disable "${SERVICE_NAME}" + echo "[OK] Autostart deaktiviert" + ;; + *) + usage + exit 1 + ;; +esac