{{ error_message }}
+ {% endif %} + +StFV Backup
+Angemeldet als {{ adminuser }}
+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 %} +
Hier kannst du deinen Benutzernamen und dein Passwort komplett aendern.
+ +Hier kannst du Name, Ziel, Verschlüsselung und Zeitplan anpassen.
+ + + + +{% 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 %} +Die Konfiguration erscheint Schritt fuer Schritt, damit du jedes Fenster nacheinander ausfuellen kannst.
+ + + + + + +{% 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 @@ + + + + + +Angemeldet als {{ adminuser }}
+Hier siehst du alle Backup-Jobs mit Zeitplan, letztem Lauf und Größe.
+ + + +{% if backups %} +| Name | +Zeitplan | +Letzter Lauf | +Größe | +Aktionen | +
|---|---|---|---|---|
| {{ backup.name }} | +{{ backup.schedule_mode }} | +{{ backup.last_run_at | fmt_dt }} | +{{ backup.last_size_bytes | fmt_bytes }} | ++ + ✎ + + | +
Noch keine Backups eingerichtet. Lege jetzt den ersten Job an.
+{{ error_message }}
+ {% endif %} + +Diese Einstellungen gelten fuer den Flask-Webserver.
+ +Lege neue Benutzer fuer den Browser-Login an und steuere Adminrechte.
+ +| Benutzername | +Rolle | +
|---|---|
| {{ user.username }} | ++ {% if user.is_admin %} + Admin + {% else %} + Benutzer + {% endif %} + | +