feat: SFTP-Backup mit Scheduler, Versionierung und Multi-Target
Mehrere SFTP-Backup-Ziele konfigurierbar mit:
- Host, Port, Benutzername, Passwort, Remote-Pfad
- Konfigurierbares Intervall (15 Min. bis woechentlich oder deaktiviert)
- Maximale Anzahl aufbewahrter Versionen (aeltere werden automatisch geloescht)
- Aktiv/Inaktiv-Toggle pro Ziel
Features:
- Automatischer Hintergrund-Scheduler prueft alle 60 Sekunden ob
Backups faellig sind und fuehrt sie aus
- Manuelles Backup per Klick ("Jetzt sichern")
- SFTP-Verbindungstest-Button
- Versionen-Dialog: Alle Backup-Versionen auf dem SFTP-Server auflisten
mit Groesse und Datum
- Restore direkt von SFTP: Version auswaehlen -> wird heruntergeladen
und ueber die bestehende DB-Merge-Logik wiederhergestellt
- Chunked Upload zum SFTP in 16MB-Bloecken (fuer grosse Backups)
- Status-Anzeige: Letztes Backup, Erfolg/Fehler, Nachricht
Backend: BackupTarget Model, SFTP-Service (paramiko), Backup-Scheduler
API: /admin/backup/targets CRUD, /test, /run, /versions, /restore
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -353,3 +353,179 @@ def _perform_restore(zip_path):
|
||||
stats['success'] = True
|
||||
stats['message'] = 'Restore erfolgreich abgeschlossen'
|
||||
return stats
|
||||
|
||||
|
||||
# ========== SFTP Backup Targets ==========
|
||||
|
||||
@api_bp.route('/admin/backup/targets', methods=['GET'])
|
||||
@admin_required
|
||||
def list_backup_targets():
|
||||
from app.models.backup_target import BackupTarget
|
||||
targets = BackupTarget.query.order_by(BackupTarget.created_at).all()
|
||||
return jsonify([t.to_dict() for t in targets]), 200
|
||||
|
||||
|
||||
@api_bp.route('/admin/backup/targets', methods=['POST'])
|
||||
@admin_required
|
||||
def create_backup_target():
|
||||
from app.models.backup_target import BackupTarget
|
||||
from app.services.crypto_service import encrypt_field
|
||||
|
||||
data = request.get_json()
|
||||
for field in ['name', 'host', 'username']:
|
||||
if not data.get(field):
|
||||
return jsonify({'error': f'{field} erforderlich'}), 400
|
||||
|
||||
if not data.get('password') and not data.get('private_key'):
|
||||
return jsonify({'error': 'Passwort oder Private Key erforderlich'}), 400
|
||||
|
||||
target = BackupTarget(
|
||||
name=data['name'],
|
||||
host=data['host'],
|
||||
port=data.get('port', 22),
|
||||
username=data['username'],
|
||||
remote_path=data.get('remote_path', '/backups/minicloud'),
|
||||
is_active=data.get('is_active', True),
|
||||
backup_interval_minutes=data.get('backup_interval_minutes', 1440),
|
||||
max_versions=data.get('max_versions', 10),
|
||||
)
|
||||
if data.get('password'):
|
||||
target.password_encrypted = encrypt_field(data['password'], 'backup-key')
|
||||
if data.get('private_key'):
|
||||
target.private_key_encrypted = encrypt_field(data['private_key'], 'backup-key')
|
||||
|
||||
db.session.add(target)
|
||||
db.session.commit()
|
||||
return jsonify(target.to_dict()), 201
|
||||
|
||||
|
||||
@api_bp.route('/admin/backup/targets/<int:target_id>', methods=['PUT'])
|
||||
@admin_required
|
||||
def update_backup_target(target_id):
|
||||
from app.models.backup_target import BackupTarget
|
||||
from app.services.crypto_service import encrypt_field
|
||||
|
||||
target = db.session.get(BackupTarget, target_id)
|
||||
if not target:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
data = request.get_json()
|
||||
for field in ['name', 'host', 'port', 'username', 'remote_path',
|
||||
'is_active', 'backup_interval_minutes', 'max_versions']:
|
||||
if field in data:
|
||||
setattr(target, field, data[field])
|
||||
|
||||
if data.get('password'):
|
||||
target.password_encrypted = encrypt_field(data['password'], 'backup-key')
|
||||
if data.get('private_key'):
|
||||
target.private_key_encrypted = encrypt_field(data['private_key'], 'backup-key')
|
||||
|
||||
db.session.commit()
|
||||
return jsonify(target.to_dict()), 200
|
||||
|
||||
|
||||
@api_bp.route('/admin/backup/targets/<int:target_id>', methods=['DELETE'])
|
||||
@admin_required
|
||||
def delete_backup_target(target_id):
|
||||
from app.models.backup_target import BackupTarget
|
||||
target = db.session.get(BackupTarget, target_id)
|
||||
if not target:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
db.session.delete(target)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Backup-Ziel geloescht'}), 200
|
||||
|
||||
|
||||
@api_bp.route('/admin/backup/targets/<int:target_id>/test', methods=['POST'])
|
||||
@admin_required
|
||||
def test_backup_target(target_id):
|
||||
from app.models.backup_target import BackupTarget
|
||||
from app.services.sftp_backup import test_sftp_connection
|
||||
|
||||
target = db.session.get(BackupTarget, target_id)
|
||||
if not target:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
try:
|
||||
test_sftp_connection(target)
|
||||
return jsonify({'message': 'SFTP-Verbindung erfolgreich'}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'Verbindungsfehler: {str(e)}'}), 400
|
||||
|
||||
|
||||
@api_bp.route('/admin/backup/targets/<int:target_id>/run', methods=['POST'])
|
||||
@admin_required
|
||||
def run_backup_now(target_id):
|
||||
"""Manually trigger a backup to this target."""
|
||||
from app.models.backup_target import BackupTarget
|
||||
from app.services.sftp_backup import create_backup_zip, upload_backup_to_sftp
|
||||
|
||||
target = db.session.get(BackupTarget, target_id)
|
||||
if not target:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
db_uri = current_app.config['SQLALCHEMY_DATABASE_URI']
|
||||
db_path = db_uri.replace('sqlite:///', '')
|
||||
upload_path = current_app.config['UPLOAD_PATH']
|
||||
|
||||
zip_path = None
|
||||
try:
|
||||
zip_path = create_backup_zip(db_path, upload_path)
|
||||
version = upload_backup_to_sftp(target, zip_path, current_app)
|
||||
|
||||
target.last_backup_at = datetime.now(timezone.utc)
|
||||
target.last_backup_status = 'success'
|
||||
target.last_backup_message = f'Version {version} hochgeladen'
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'message': f'Backup {version} erfolgreich', 'version': version}), 200
|
||||
except Exception as e:
|
||||
target.last_backup_at = datetime.now(timezone.utc)
|
||||
target.last_backup_status = 'error'
|
||||
target.last_backup_message = str(e)[:500]
|
||||
db.session.commit()
|
||||
return jsonify({'error': f'Backup fehlgeschlagen: {str(e)}'}), 500
|
||||
finally:
|
||||
if zip_path and os.path.exists(zip_path):
|
||||
os.unlink(zip_path)
|
||||
|
||||
|
||||
@api_bp.route('/admin/backup/targets/<int:target_id>/versions', methods=['GET'])
|
||||
@admin_required
|
||||
def list_backup_versions(target_id):
|
||||
from app.models.backup_target import BackupTarget
|
||||
from app.services.sftp_backup import list_sftp_versions
|
||||
|
||||
target = db.session.get(BackupTarget, target_id)
|
||||
if not target:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
try:
|
||||
versions = list_sftp_versions(target)
|
||||
return jsonify(versions), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route('/admin/backup/targets/<int:target_id>/restore/<version_name>', methods=['POST'])
|
||||
@admin_required
|
||||
def restore_from_sftp(target_id, version_name):
|
||||
"""Download a backup version from SFTP and restore it."""
|
||||
from app.models.backup_target import BackupTarget
|
||||
from app.services.sftp_backup import download_version_from_sftp
|
||||
|
||||
target = db.session.get(BackupTarget, target_id)
|
||||
if not target:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
zip_path = None
|
||||
try:
|
||||
zip_path = download_version_from_sftp(target, version_name)
|
||||
result = _perform_restore(zip_path)
|
||||
return jsonify(result), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'Restore fehlgeschlagen: {str(e)}'}), 500
|
||||
finally:
|
||||
if zip_path and os.path.exists(zip_path):
|
||||
os.unlink(zip_path)
|
||||
|
||||
Reference in New Issue
Block a user