diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index f79bff292e..178f58bb74 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -57,7 +57,7 @@ class UpdateConfig: - DATASTORE_VERSION = 113 + DATASTORE_VERSION = 114 valid_topic = [ "^openWB/bat/config/bat_control_permitted$", @@ -710,6 +710,9 @@ def update(self): try: # erst breaking changes auflösen, sonst sind alte Topics schon gelöscht self.__solve_breaking_changes() + # Normalisiere neue Felder auch im normalen Update-Pfad, nicht nur in + # upgrade_datastore_* (UI kann aktuell bei "Feld geleert" null übermitteln). + self.__normalize_backup_cloud_max_backups() self.__remove_outdated_topics() self._remove_invalid_topics() self.__pub_missing_defaults() @@ -810,6 +813,31 @@ def __solve_breaking_changes(self) -> None: pub_system_message( {}, "Fehler bei der Aktualisierung der Konfiguration des Brokers.", MessageType.ERROR) + def __normalize_backup_cloud_max_backups(self) -> None: + """ + Serverseitige Normalisierung für `configuration.max_backups` im + normalen Update-Pfad. + + Semantik: + - `0` => automatische Löschung deaktiviert + """ + for topic, payload in list(self.all_received_topics.items()): + if topic != "openWB/system/backup_cloud/config": + continue + + configuration_payload = decode_payload(payload) + if not isinstance(configuration_payload, dict): + continue + + cloud_type = configuration_payload.get("type") + if cloud_type not in ("nextcloud", "samba"): + continue + + configuration_payload.setdefault("configuration", {}) + if configuration_payload["configuration"].get("max_backups") is None: + configuration_payload["configuration"]["max_backups"] = 0 + self.__update_topic(topic, configuration_payload) + def _loop_all_received_topics(self, callback) -> None: modified_topics = {} for topic, payload in self.all_received_topics.items(): @@ -2958,3 +2986,30 @@ def upgrade(topic: str, payload) -> None: self._loop_all_received_topics(upgrade) self._append_datastore_version(113) + + def upgrade_datastore_114(self) -> None: + """ + Ensure new backup cloud configuration field `max_backups` is present. + + For older datastores the field may be missing; normalizing to `0` + guarantees the semantics "0 = keine automatische Löschung" and + prevents null/undefined being written back from the UI. + """ + + def upgrade(topic: str, payload) -> Optional[dict]: + if re.search(r"^openWB/system/backup_cloud/config$", topic) is None: + return None + + configuration_payload = decode_payload(payload) + cloud_type = configuration_payload.get("type") + if cloud_type not in ("nextcloud", "samba"): + return None + + configuration_payload.setdefault("configuration", {}) + if configuration_payload["configuration"].get("max_backups") is None: + configuration_payload["configuration"]["max_backups"] = 0 + + return {topic: configuration_payload} + + self._loop_all_received_topics(upgrade) + self._append_datastore_version(114) diff --git a/packages/modules/backup_clouds/nextcloud/backup_cloud.py b/packages/modules/backup_clouds/nextcloud/backup_cloud.py index c059190f2f..7e0c0937ac 100644 --- a/packages/modules/backup_clouds/nextcloud/backup_cloud.py +++ b/packages/modules/backup_clouds/nextcloud/backup_cloud.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import logging import re +from typing import List from modules.backup_clouds.nextcloud.config import NextcloudBackupCloud, NextcloudBackupCloudConfiguration from modules.common import req @@ -9,26 +10,150 @@ log = logging.getLogger(__name__) -def upload_backup(config: NextcloudBackupCloudConfiguration, backup_filename: str, backup_file: bytes) -> None: +def _parse_nextcloud_base_url_and_user(config: NextcloudBackupCloudConfiguration, backup_filename: str): + """ + Liefert Basis-URL (ohne /public.php/webdav/...) und Benutzer (Token oder User). + Zusätzlich wird der WebDAV-Pfad zum Backup-Verzeichnis zurückgegeben. + """ if config.user is None: url_match = re.fullmatch(r'(http[s]?):\/\/([\S^/]+)\/(?:index.php\/)?s\/(.+)', config.ip_address) if not url_match: - raise ValueError(f"URL '{config.ip_address}' hat nicht die erwartete Form " - "'https://server/index.php/s/user_token' oder 'https://server/s/user_token'") + raise ValueError( + f"URL '{config.ip_address}' hat nicht die erwartete Form " + "'https://server/index.php/s/user_token' oder 'https://server/s/user_token'" + ) upload_url = f"{url_match[1]}://{url_match[2]}" user = url_match[url_match.lastindex] + base_path = "/public.php/webdav" else: upload_url = config.ip_address user = config.user + # Für Benutzer-Accounts ist normalerweise /remote.php/dav/files// üblich. + # In dieser Implementierung verwenden wir aber bewusst weiterhin den + # öffentlichen WebDAV-Pfad wie beim vorherigen Verhalten: + # /public.php/webdav/ + base_path = "/public.php/webdav" + + return upload_url, user, base_path + + +def _list_backups(config: NextcloudBackupCloudConfiguration, + backup_filename: str) -> List[str]: + """ + Listet alle vorhandenen Backupdateien, die zum gleichen OpenWB-Suffix gehören + (Pattern-Match am Dateinamen) und gibt eine nach Dateinamen sortierte Liste zurück. + """ + max_backups = getattr(config, "max_backups", None) + if not max_backups or max_backups <= 0: + return [] + + upload_url, user, base_path = _parse_nextcloud_base_url_and_user(config, backup_filename) + + # Robust gruppieren: OpenWB-Backups enden entweder auf ".openwb-backup" + # oder auf ".openwb-backup.gpg". Der Teil vor dem ersten Punkt kann + # timestamps-/versionspezifisch sein und darf daher nicht zum Gruppieren + # verwendet werden. + base_name = backup_filename.split("/")[-1] + if base_name.endswith(".openwb-backup.gpg"): + required_suffix = ".openwb-backup.gpg" + elif base_name.endswith(".openwb-backup"): + required_suffix = ".openwb-backup" + else: + log.warning("Nextcloud Retention: Unerwartetes Backup-Dateimuster: %s", base_name) + return [] + + # WebDAV PROPFIND, um Dateiliste zu bekommen + list_path = f"{base_path}/" + response = req.get_http_session().request( + "PROPFIND", + f"{upload_url}{list_path}", + headers={ + "Depth": "1", + "Content-Type": "text/xml", + }, + data=""" + + + + +""", + auth=(user, "" if config.password is None else config.password), + timeout=60, + ) + + if not response.ok: + log.warning("Nextcloud PROPFIND für Backup-Liste fehlgeschlagen: %s %s", + response.status_code, response.reason) + return [] + + # Sehr einfache Auswertung: nach ... parsen + # und alle Einträge sammeln, die auf unser Suffix enden. + names: List[str] = [] + for match in re.finditer(r"([^<]+)", response.text): + name = match.group(1) + if name.endswith(required_suffix): + names.append(name) + # Alphabetisch sortieren – entspricht der im Issue gewünschten Sortierung nach Dateinamen + names.sort() + return names + + +def _enforce_retention(config: NextcloudBackupCloudConfiguration, backup_filename: str) -> None: + """ + Löscht alte Nextcloud-Backups, so dass höchstens max_backups Dateien mit dem + gleichen OpenWB-Suffix (Pattern-Match) übrig bleiben. Sortierung erfolgt nach + Dateinamen, es bleiben die letzten N erhalten. + """ + max_backups = getattr(config, "max_backups", None) + if not max_backups or max_backups <= 0: + return + + upload_url, user, base_path = _parse_nextcloud_base_url_and_user(config, backup_filename) + all_backups = _list_backups(config, backup_filename) + if len(all_backups) <= max_backups: + return + + # Alle außer den letzten max_backups löschen + to_delete = all_backups[:-max_backups] + + for name in to_delete: + delete_path = f"{base_path}/{name}" if base_path else name + try: + log.info("Lösche altes Nextcloud-Backup: %s", delete_path) + resp = req.get_http_session().delete( + f"{upload_url}{delete_path}", + headers={"X-Requested-With": "XMLHttpRequest"}, + auth=(user, "" if config.password is None else config.password), + timeout=60, + ) + if not resp.ok: + log.warning("Löschen des Nextcloud-Backups '%s' fehlgeschlagen: %s %s", + delete_path, resp.status_code, resp.reason) + except Exception as error: + log.error("Fehler beim Löschen alter Nextcloud-Backups (%s): %s", + delete_path, str(error).split("\n")[0]) + + +def upload_backup(config: NextcloudBackupCloudConfiguration, backup_filename: str, backup_file: bytes) -> None: + upload_url, user, base_path = _parse_nextcloud_base_url_and_user(config, backup_filename) + + # Backup-Datei hochladen req.get_http_session().put( - f'{upload_url}/public.php/webdav/{backup_filename}', + f"{upload_url}{base_path}/{backup_filename.lstrip('/')}", headers={'X-Requested-With': 'XMLHttpRequest', }, data=backup_file, auth=(user, '' if config.password is None else config.password), timeout=60 ) + # Aufbewahrung alter Backups erzwingen (wenn konfiguriert) + try: + _enforce_retention(config, backup_filename) + except Exception as error: + log.error("Fehler bei der Bereinigung alter Nextcloud-Backups: %s", + str(error).split("\n")[0]) + def create_backup_cloud(config: NextcloudBackupCloud): def updater(backup_filename: str, backup_file: bytes): diff --git a/packages/modules/backup_clouds/nextcloud/config.py b/packages/modules/backup_clouds/nextcloud/config.py index 6f19a8bd13..972c85ef60 100644 --- a/packages/modules/backup_clouds/nextcloud/config.py +++ b/packages/modules/backup_clouds/nextcloud/config.py @@ -2,10 +2,16 @@ class NextcloudBackupCloudConfiguration: - def __init__(self, ip_address: Optional[str] = None, user: Optional[str] = None, password: Optional[str] = None): + def __init__(self, + ip_address: Optional[str] = None, + user: Optional[str] = None, + password: Optional[str] = None, + max_backups: Optional[int] = None): self.ip_address = ip_address self.user = user self.password = password + # None oder <= 0 bedeutet: keine automatische Löschung alter Backups + self.max_backups = max_backups class NextcloudBackupCloud: diff --git a/packages/modules/backup_clouds/samba/backup_cloud.py b/packages/modules/backup_clouds/samba/backup_cloud.py index 49b1145343..9ee9a54a1f 100644 --- a/packages/modules/backup_clouds/samba/backup_cloud.py +++ b/packages/modules/backup_clouds/samba/backup_cloud.py @@ -28,6 +28,67 @@ def is_port_open(host: str, port: int): s.close() +def _enforce_retention(conn, config: SambaBackupCloudConfiguration, backup_filename: str) -> None: + """ + Löscht alte Backups auf dem SMB-Share, wenn mehr als max_backups vorhanden sind. + Es werden nur Dateien berücksichtigt, die auf ".openwb-backup" bzw. + ".openwb-backup.gpg" enden (stabiler Filter; der Teil vor der Endung ist + timestamps-/versionspezifisch und darf nicht zum Gruppieren verwendet werden). + """ + max_backups = getattr(config, "max_backups", None) + if not max_backups or max_backups <= 0: + return + + # Verzeichnis ermitteln, in dem die Backups liegen + smb_path = config.smb_path or "/" + smb_path = smb_path.rstrip("/") + if smb_path == "": + dir_path = "/" + else: + dir_path = smb_path + + base_name = os.path.basename(backup_filename) + if base_name.endswith(".openwb-backup.gpg"): + required_suffix = ".openwb-backup.gpg" + elif base_name.endswith(".openwb-backup"): + required_suffix = ".openwb-backup" + else: + # Wenn das Dateimuster nicht passt, vermeiden wir versehentliches + # Löschen fremder Dateien auf dem Share. + log.warning("Samba Retention: Unerwartetes Backup-Dateimuster: %s", base_name) + return [] + + # Alle Einträge im Backup-Verzeichnis holen + entries = conn.listPath(config.smb_share, dir_path) + + # Nur relevante Backup-Dateien herausfiltern + backup_files = [ + e for e in entries + if not e.isDirectory + and e.filename not in (".", "..") + and e.filename.endswith(required_suffix) + ] + + if len(backup_files) <= max_backups: + return + + # Nach Dateiname sortieren (Issue #2020: Dateiname enthält die Reihenfolge) + backup_files.sort(key=lambda e: e.filename) + + # Ältere Backups über dem Limit löschen (alles bis auf die letzten max_backups) + for old_entry in backup_files[:-max_backups]: + if dir_path in ("", "/"): + delete_path = f"/{old_entry.filename}" + else: + delete_path = f"{dir_path.rstrip('/')}/{old_entry.filename}" + try: + log.info("Lösche altes Samba-Backup: //%s/%s%s", config.smb_server, config.smb_share, delete_path) + conn.deleteFiles(config.smb_share, delete_path) + except Exception as error: + # Fehler beim Aufräumen sollen das eigentliche Backup nicht fehlschlagen lassen + log.error("Fehler beim Löschen alter Samba-Backups (%s): %s", delete_path, str(error).split("\n")[0]) + + def upload_backup(config: SambaBackupCloudConfiguration, backup_filename: str, backup_file: bytes) -> None: SMB_PORT_445 = 445 SMB_PORT_139 = 139 @@ -57,9 +118,19 @@ def upload_backup(config: SambaBackupCloudConfiguration, backup_filename: str, b conn.storeFile(config.smb_share, full_file_path, io.BytesIO(backup_file)) + try: + _enforce_retention(conn, config, backup_filename) + except Exception as error: + log.error( + "Fehler bei der Bereinigung alter Samba-Backups (Port 445): %s", + str(error).split("\n")[0], + ) + return except Exception as error: - raise Exception("Freigabe oder Unterordner existiert möglicherweise nicht. "+str(error).split('\n')[0]) + raise Exception( + "Freigabe oder Unterordner existiert möglicherweise nicht. " + str(error).split("\n")[0] + ) finally: conn.close() else: @@ -88,6 +159,14 @@ def upload_backup(config: SambaBackupCloudConfiguration, backup_filename: str, b log.info(f"Backup nach //{config.smb_server}/{config.smb_share}/{full_file_path}") conn.storeFile(config.smb_share, full_file_path, io.BytesIO(backup_file)) + + try: + _enforce_retention(conn, config, backup_filename) + except Exception as error: + log.error( + "Fehler bei der Bereinigung alter Samba-Backups (Port 139): %s", + str(error).split("\n")[0], + ) except Exception as error: raise Exception( "Möglicherweise ist die Freigabe oder ein Unterordner nicht vorhanden." diff --git a/packages/modules/backup_clouds/samba/config.py b/packages/modules/backup_clouds/samba/config.py index f8f5e483fc..91e598e6fd 100644 --- a/packages/modules/backup_clouds/samba/config.py +++ b/packages/modules/backup_clouds/samba/config.py @@ -7,12 +7,15 @@ def __init__(self, smb_server: Optional[str] = None, smb_share: Optional[str] = None, smb_user: Optional[str] = None, - smb_password: Optional[str] = None): + smb_password: Optional[str] = None, + max_backups: Optional[int] = None): self.smb_path = smb_path self.smb_server = smb_server self.smb_share = smb_share self.smb_user = smb_user self.smb_password = smb_password + # None oder <= 0 bedeutet: keine automatische Löschung alter Backups + self.max_backups = max_backups class SambaBackupCloud: