Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 129 additions & 4 deletions packages/modules/backup_clouds/nextcloud/backup_cloud.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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/<user>/ üblich.
# In dieser Implementierung verwenden wir aber bewusst weiterhin den
# öffentlichen WebDAV-Pfad wie beim vorherigen Verhalten:
# /public.php/webdav/<filename>
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="""<?xml version="1.0" encoding="utf-8" ?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:displayname />
</d:prop>
</d:propfind>""",
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 <d:displayname>...</d:displayname> parsen
# und alle Einträge sammeln, die auf unser Suffix enden.
names: List[str] = []
for match in re.finditer(r"<d:displayname>([^<]+)</d:displayname>", 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):
Expand Down
8 changes: 7 additions & 1 deletion packages/modules/backup_clouds/nextcloud/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
81 changes: 80 additions & 1 deletion packages/modules/backup_clouds/samba/backup_cloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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."
Expand Down
5 changes: 4 additions & 1 deletion packages/modules/backup_clouds/samba/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading