From b3c883213b09b2cfe87befcbff5b749e3bc80def Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 12:50:32 +0000 Subject: [PATCH 01/74] Implementazione sistema backup CRI Catania - FastAPI + SQLite/SQLAlchemy: modelli Server, VMwareHost, BackupDestination, BackupJob, BackupRun, BackupLog - Engine backup: ESXi snapshot+OVF (pyvmomi), dati Windows via WinRM/PowerShell, dati Linux via SSH/rsync - Trasferimento QNAP: rsync-over-SSH e API QTS, con gestione retention automatica - Scheduler APScheduler con espressioni cron configurabili - UI Bootstrap 5: dashboard con statistiche live, wizard guidato (server/QNAP/job), storico esecuzioni, viewer log real-time - Cifratura credenziali nel DB tramite Fernet https://claude.ai/code/session_01AfKVb7RehwV197JyXeeqab --- .env.example | 10 + .gitignore | 9 + README.md | 69 +++++- app/__init__.py | 0 app/api/__init__.py | 0 app/api/destinations.py | 92 ++++++++ app/api/jobs.py | 104 +++++++++ app/api/runs.py | 46 ++++ app/api/servers.py | 139 ++++++++++++ app/backup/__init__.py | 0 app/backup/engine.py | 124 +++++++++++ app/backup/linux.py | 110 ++++++++++ app/backup/qnap.py | 141 ++++++++++++ app/backup/vmware.py | 168 ++++++++++++++ app/backup/windows.py | 126 +++++++++++ app/crypto.py | 31 +++ app/database.py | 28 +++ app/main.py | 79 +++++++ app/models.py | 153 +++++++++++++ app/scheduler.py | 60 +++++ requirements.txt | 15 ++ run.py | 15 ++ templates/base.html | 99 +++++++++ templates/dashboard.html | 148 +++++++++++++ templates/history.html | 76 +++++++ templates/jobs.html | 84 +++++++ templates/logs.html | 80 +++++++ templates/wizard_destination.html | 165 ++++++++++++++ templates/wizard_job.html | 158 ++++++++++++++ templates/wizard_server.html | 352 ++++++++++++++++++++++++++++++ 30 files changed, 2680 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/destinations.py create mode 100644 app/api/jobs.py create mode 100644 app/api/runs.py create mode 100644 app/api/servers.py create mode 100644 app/backup/__init__.py create mode 100644 app/backup/engine.py create mode 100644 app/backup/linux.py create mode 100644 app/backup/qnap.py create mode 100644 app/backup/vmware.py create mode 100644 app/backup/windows.py create mode 100644 app/crypto.py create mode 100644 app/database.py create mode 100644 app/main.py create mode 100644 app/models.py create mode 100644 app/scheduler.py create mode 100644 requirements.txt create mode 100644 run.py create mode 100644 templates/base.html create mode 100644 templates/dashboard.html create mode 100644 templates/history.html create mode 100644 templates/jobs.html create mode 100644 templates/logs.html create mode 100644 templates/wizard_destination.html create mode 100644 templates/wizard_job.html create mode 100644 templates/wizard_server.html diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d76912e --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Copia questo file in .env e adattalo +PORT=8000 +DEV=false + +# Chiave di cifratura credenziali (generata automaticamente al primo avvio) +# Per produzione: genera con: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +# BACKUP_SECRET_KEY=... + +# Percorso DB (default: SQLite locale) +# DATABASE_URL=sqlite:///./backup_all.db diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6de76b9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +*.pyo +.env +.secret_key +backup_all.db +*.db-journal +*.db-wal +*.db-shm diff --git a/README.md b/README.md index 21a1fa7..e3dc8f7 100644 --- a/README.md +++ b/README.md @@ -1 +1,68 @@ -# Backup-all \ No newline at end of file +# Backup-All — CRI Catania + +Sistema automatizzato di backup per VM VMware ESXi con UI web di configurazione. + +## Architettura + +- **Backend**: Python 3.11 + FastAPI +- **Database**: SQLite (via SQLAlchemy) — sostituibile con PostgreSQL/MySQL +- **Scheduler**: APScheduler (cron) +- **Backup VM**: pyvmomi → ESXi snapshot + export OVF +- **Backup dati Windows** (Gamma/TeamSystem): WinRM + PowerShell +- **Backup dati Linux** (Abulafia): SSH + rsync + pg_dump +- **Destinazione**: QNAP via rsync-over-SSH o API QTS + +## Installazione + +```bash +pip install -r requirements.txt +python run.py +``` + +Apri il browser su `http://localhost:8000` + +## Primo avvio — wizard configurazione + +1. **Aggiungi Server** → `/wizard/server` + - Configura Windows Server (Gamma) e Debian (Abulafia) + - Inserisci credenziali WinRM / SSH + - Collega all'host ESXi per backup OVF + +2. **Aggiungi QNAP** → `/wizard/destination` + - IP del NAS, credenziali SSH, percorso base + +3. **Crea Job** → `/wizard/job` + - Scegli server, destinazione, tipo backup + - Configura schedule cron (o usa i preset) + +## Prerequisiti + +### Server Windows (Gamma) +```powershell +winrm quickconfig -y +winrm set winrm/config/service/auth '@{Basic="true"}' +``` + +### Server Debian (Abulafia) +- SSH abilitato con utente con accesso ai dati + +### QNAP +- Abilitare SSH: `Pannello di Controllo > Terminale & SNMP > Abilita SSH` +- Creare utente dedicato backup con accesso alla share + +### ESXi +- Utente con ruolo Administrator o almeno permessi snapshot + datastore + +## Struttura backup sul QNAP + +``` +/share/Backup/cri-catania/ + windows-gamma/ + 2024-01-15_02-00-00/ + vm_export/ <- OVF + VMDK + app_data/ <- file Gamma + .bak SQL Server + debian-abulafia/ + 2024-01-15_02-00-00/ + vm_export/ + app_data/ <- dati + dump PostgreSQL +``` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/destinations.py b/app/api/destinations.py new file mode 100644 index 0000000..efd3b88 --- /dev/null +++ b/app/api/destinations.py @@ -0,0 +1,92 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from pydantic import BaseModel +from typing import Optional + +from app.database import get_db +from app.models import BackupDestination +from app.crypto import encrypt + +router = APIRouter(prefix="/api/destinations", tags=["destinations"]) + + +class DestinationCreate(BaseModel): + name: str + description: Optional[str] = None + dest_type: str # "rsync", "qnap_api" + host: str + port: Optional[int] = None + username: Optional[str] = None + password: Optional[str] = None + base_path: str + rsync_module: Optional[str] = None + max_retention_days: int = 30 + + +@router.get("") +def list_destinations(db: Session = Depends(get_db)): + dests = db.query(BackupDestination).all() + return [_serialize(d) for d in dests] + + +@router.post("", status_code=201) +def create_destination(data: DestinationCreate, db: Session = Depends(get_db)): + d = BackupDestination( + name=data.name, description=data.description, + dest_type=data.dest_type, host=data.host, port=data.port, + username=data.username, + password_enc=encrypt(data.password) if data.password else None, + base_path=data.base_path, + rsync_module=data.rsync_module, + max_retention_days=data.max_retention_days, + ) + db.add(d) + db.commit() + db.refresh(d) + return {"id": d.id, "name": d.name} + + +@router.post("/{dest_id}/test") +def test_destination(dest_id: int, db: Session = Depends(get_db)): + """Verifica la connessione al QNAP.""" + d = db.get(BackupDestination, dest_id) + if not d: + raise HTTPException(404, "Destinazione non trovata") + import subprocess, os + from app.crypto import decrypt + password = decrypt(d.password_enc) if d.password_enc else "" + port = d.port or 22 + env = os.environ.copy() + if password: + env["SSHPASS"] = password + ssh_prefix = ["sshpass", "-e", "ssh"] + else: + ssh_prefix = ["ssh"] + result = subprocess.run( + ssh_prefix + ["-p", str(port), "-o", "ConnectTimeout=5", + "-o", "StrictHostKeyChecking=no", + f"{d.username}@{d.host}", "echo OK"], + capture_output=True, text=True, env=env, timeout=10 + ) + if result.returncode == 0: + return {"ok": True, "message": "Connessione al QNAP riuscita"} + raise HTTPException(500, f"Connessione fallita: {result.stderr}") + + +@router.delete("/{dest_id}") +def delete_destination(dest_id: int, db: Session = Depends(get_db)): + d = db.get(BackupDestination, dest_id) + if not d: + raise HTTPException(404, "Destinazione non trovata") + db.delete(d) + db.commit() + return {"ok": True} + + +def _serialize(d: BackupDestination) -> dict: + return { + "id": d.id, "name": d.name, "description": d.description, + "dest_type": d.dest_type, "host": d.host, "port": d.port, + "username": d.username, "base_path": d.base_path, + "max_retention_days": d.max_retention_days, "is_active": d.is_active, + } diff --git a/app/api/jobs.py b/app/api/jobs.py new file mode 100644 index 0000000..15957fd --- /dev/null +++ b/app/api/jobs.py @@ -0,0 +1,104 @@ +from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks +from sqlalchemy.orm import Session +from pydantic import BaseModel +from typing import Optional + +from app.database import get_db +from app.models import BackupJob, BackupType, JobStatus + +router = APIRouter(prefix="/api/jobs", tags=["jobs"]) + + +class JobCreate(BaseModel): + name: str + description: Optional[str] = None + server_id: int + destination_id: int + backup_type: BackupType + cron_expression: str + retention_copies: int = 7 + compression: bool = True + notify_email: Optional[str] = None + + +@router.get("") +def list_jobs(db: Session = Depends(get_db)): + jobs = db.query(BackupJob).all() + return [_serialize(j) for j in jobs] + + +@router.post("", status_code=201) +def create_job(data: JobCreate, db: Session = Depends(get_db)): + job = BackupJob( + name=data.name, description=data.description, + server_id=data.server_id, destination_id=data.destination_id, + backup_type=data.backup_type, cron_expression=data.cron_expression, + retention_copies=data.retention_copies, compression=data.compression, + notify_email=data.notify_email, + ) + db.add(job) + db.commit() + db.refresh(job) + # Ricarica scheduler + from app.scheduler import load_jobs_from_db + load_jobs_from_db() + return {"id": job.id, "name": job.name} + + +@router.post("/{job_id}/run") +def run_job_now(job_id: int, background_tasks: BackgroundTasks, db: Session = Depends(get_db)): + """Avvia manualmente un job in background.""" + job = db.get(BackupJob, job_id) + if not job: + raise HTTPException(404, "Job non trovato") + + def _run(): + from app.backup.engine import run_job + from app.database import SessionLocal + session = SessionLocal() + try: + run_job(job_id, session, triggered_by="manual") + finally: + session.close() + + background_tasks.add_task(_run) + return {"ok": True, "message": f"Job '{job.name}' avviato in background"} + + +@router.patch("/{job_id}/status") +def set_job_status(job_id: int, status: JobStatus, db: Session = Depends(get_db)): + job = db.get(BackupJob, job_id) + if not job: + raise HTTPException(404, "Job non trovato") + job.status = status + db.commit() + from app.scheduler import load_jobs_from_db + load_jobs_from_db() + return {"ok": True} + + +@router.delete("/{job_id}") +def delete_job(job_id: int, db: Session = Depends(get_db)): + job = db.get(BackupJob, job_id) + if not job: + raise HTTPException(404, "Job non trovato") + db.delete(job) + db.commit() + from app.scheduler import load_jobs_from_db + load_jobs_from_db() + return {"ok": True} + + +def _serialize(j: BackupJob) -> dict: + return { + "id": j.id, "name": j.name, "description": j.description, + "server_id": j.server_id, + "server_name": j.server.name if j.server else None, + "destination_id": j.destination_id, + "destination_name": j.destination.name if j.destination else None, + "backup_type": j.backup_type, "status": j.status, + "cron_expression": j.cron_expression, + "retention_copies": j.retention_copies, + "last_run_at": j.last_run_at.isoformat() if j.last_run_at else None, + "last_run_status": j.last_run_status, + } diff --git a/app/api/runs.py b/app/api/runs.py new file mode 100644 index 0000000..d63ec72 --- /dev/null +++ b/app/api/runs.py @@ -0,0 +1,46 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models import BackupRun, BackupLog + +router = APIRouter(prefix="/api/runs", tags=["runs"]) + + +@router.get("") +def list_runs(job_id: int = None, limit: int = 50, db: Session = Depends(get_db)): + q = db.query(BackupRun).order_by(BackupRun.started_at.desc()) + if job_id: + q = q.filter(BackupRun.job_id == job_id) + runs = q.limit(limit).all() + return [_serialize(r) for r in runs] + + +@router.get("/{run_id}") +def get_run(run_id: int, db: Session = Depends(get_db)): + r = db.get(BackupRun, run_id) + if not r: + raise HTTPException(404, "Run non trovata") + return _serialize(r) + + +@router.get("/{run_id}/logs") +def get_run_logs(run_id: int, db: Session = Depends(get_db)): + logs = db.query(BackupLog).filter(BackupLog.run_id == run_id).order_by(BackupLog.timestamp).all() + return [{"timestamp": l.timestamp.isoformat(), "level": l.level, "message": l.message} + for l in logs] + + +def _serialize(r: BackupRun) -> dict: + return { + "id": r.id, + "job_id": r.job_id, + "job_name": r.job.name if r.job else None, + "status": r.status, + "started_at": r.started_at.isoformat() if r.started_at else None, + "finished_at": r.finished_at.isoformat() if r.finished_at else None, + "size_bytes": r.size_bytes, + "backup_path": r.backup_path, + "error_message": r.error_message, + "triggered_by": r.triggered_by, + } diff --git a/app/api/servers.py b/app/api/servers.py new file mode 100644 index 0000000..702ecf8 --- /dev/null +++ b/app/api/servers.py @@ -0,0 +1,139 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from pydantic import BaseModel +from typing import Optional +import json + +from app.database import get_db +from app.models import Server, VMwareHost, ServerType +from app.crypto import encrypt, decrypt + +router = APIRouter(prefix="/api/servers", tags=["servers"]) + + +class VMwareHostCreate(BaseModel): + name: str + host: str + port: int = 443 + username: str + password: str + ssl_verify: bool = False + + +class ServerCreate(BaseModel): + name: str + description: Optional[str] = None + server_type: ServerType + ip_address: str + vm_name: Optional[str] = None + vmware_host_id: Optional[int] = None + ssh_port: int = 22 + winrm_port: int = 5985 + username: Optional[str] = None + password: Optional[str] = None + ssh_key_path: Optional[str] = None + app_name: Optional[str] = None + app_data_paths: Optional[list[str]] = None + app_db_type: Optional[str] = None + app_db_name: Optional[str] = None + app_db_user: Optional[str] = None + app_db_password: Optional[str] = None + + +# ── VMware Hosts ────────────────────────────────────── + +@router.get("/vmware-hosts") +def list_vmware_hosts(db: Session = Depends(get_db)): + hosts = db.query(VMwareHost).all() + return [{"id": h.id, "name": h.name, "host": h.host, "port": h.port, + "username": h.username} for h in hosts] + + +@router.post("/vmware-hosts", status_code=201) +def create_vmware_host(data: VMwareHostCreate, db: Session = Depends(get_db)): + host = VMwareHost( + name=data.name, host=data.host, port=data.port, + username=data.username, password_enc=encrypt(data.password), + ssl_verify=data.ssl_verify, + ) + db.add(host) + db.commit() + db.refresh(host) + return {"id": host.id, "name": host.name} + + +@router.get("/vmware-hosts/{host_id}/vms") +def list_vms_on_host(host_id: int, db: Session = Depends(get_db)): + host = db.get(VMwareHost, host_id) + if not host: + raise HTTPException(404, "Host VMware non trovato") + from app.backup.vmware import list_vms + try: + return list_vms(host) + except Exception as e: + raise HTTPException(500, str(e)) + + +# ── Servers ─────────────────────────────────────────── + +@router.get("") +def list_servers(db: Session = Depends(get_db)): + servers = db.query(Server).all() + return [_serialize(s) for s in servers] + + +@router.get("/{server_id}") +def get_server(server_id: int, db: Session = Depends(get_db)): + s = db.get(Server, server_id) + if not s: + raise HTTPException(404, "Server non trovato") + return _serialize(s) + + +@router.post("", status_code=201) +def create_server(data: ServerCreate, db: Session = Depends(get_db)): + s = Server( + name=data.name, + description=data.description, + server_type=data.server_type, + ip_address=data.ip_address, + vm_name=data.vm_name, + vmware_host_id=data.vmware_host_id, + ssh_port=data.ssh_port, + winrm_port=data.winrm_port, + username=data.username, + password_enc=encrypt(data.password) if data.password else None, + ssh_key_path=data.ssh_key_path, + app_name=data.app_name, + app_data_paths=json.dumps(data.app_data_paths or []), + app_db_type=data.app_db_type, + app_db_name=data.app_db_name, + app_db_user=data.app_db_user, + app_db_password_enc=encrypt(data.app_db_password) if data.app_db_password else None, + ) + db.add(s) + db.commit() + db.refresh(s) + return {"id": s.id, "name": s.name} + + +@router.delete("/{server_id}") +def delete_server(server_id: int, db: Session = Depends(get_db)): + s = db.get(Server, server_id) + if not s: + raise HTTPException(404, "Server non trovato") + db.delete(s) + db.commit() + return {"ok": True} + + +def _serialize(s: Server) -> dict: + return { + "id": s.id, "name": s.name, "description": s.description, + "server_type": s.server_type, "ip_address": s.ip_address, + "vm_name": s.vm_name, "vmware_host_id": s.vmware_host_id, + "app_name": s.app_name, + "app_data_paths": json.loads(s.app_data_paths or "[]"), + "app_db_type": s.app_db_type, "app_db_name": s.app_db_name, + "is_active": s.is_active, + } diff --git a/app/backup/__init__.py b/app/backup/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/backup/engine.py b/app/backup/engine.py new file mode 100644 index 0000000..9cb7afb --- /dev/null +++ b/app/backup/engine.py @@ -0,0 +1,124 @@ +"""Orchestratore backup: coordina VMware, Linux/Windows e QNAP.""" +import os +import shutil +import tempfile +from datetime import datetime, timezone + +from sqlalchemy.orm import Session + +from app.models import BackupJob, BackupRun, BackupLog, BackupType, RunStatus +from app.backup import vmware, linux, windows, qnap + + +def _log(db: Session, run: BackupRun, message: str, level: str = "INFO"): + entry = BackupLog(run_id=run.id, message=message, level=level) + db.add(entry) + db.commit() + print(f"[{level}] {message}") + + +def run_job(job_id: int, db: Session, triggered_by: str = "scheduler") -> BackupRun: + job: BackupJob = db.get(BackupJob, job_id) + if not job: + raise ValueError(f"Job {job_id} non trovato") + + run = BackupRun(job_id=job.id, status=RunStatus.RUNNING, triggered_by=triggered_by) + db.add(run) + db.commit() + db.refresh(run) + + log = lambda msg, level="INFO": _log(db, run, msg, level) + + tmp_dir = tempfile.mkdtemp(prefix="backup_") + try: + server = job.server + dest_cfg = job.destination + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d_%H-%M-%S") + remote_subpath = f"{server.name}/{timestamp}" + + # ── SNAPSHOT VM ────────────────────────────────────────── + if job.backup_type in (BackupType.VM_SNAPSHOT, BackupType.FULL): + if not server.vm_name or not server.vmware_host: + log("vm_name o vmware_host non configurati, skip snapshot", "WARNING") + else: + snap_name = f"backup_{timestamp}" + log(f"Creazione snapshot VMware '{snap_name}'...") + vmware.create_snapshot(server.vmware_host, server.vm_name, snap_name, log) + + vm_dir = os.path.join(tmp_dir, "vm_export") + os.makedirs(vm_dir) + log("Export VM OVF...") + vmware.export_vm_ovf(server.vmware_host, server.vm_name, vm_dir, log) + + log("Rimozione snapshot temporaneo...") + vmware.remove_snapshot(server.vmware_host, server.vm_name, snap_name, log) + + # ── DATI APPLICATIVI ───────────────────────────────────── + if job.backup_type in (BackupType.APP_DATA, BackupType.FULL): + app_dir = os.path.join(tmp_dir, "app_data") + os.makedirs(app_dir) + + from app.models import ServerType + if server.server_type == ServerType.LINUX: + log("Backup dati Abulafia (Linux)...") + linux.backup_app_data(server, app_dir, log) + if server.app_db_type: + linux.backup_database(server, app_dir, log) + + elif server.server_type == ServerType.WINDOWS: + log("Backup dati Gamma/TeamSystem (Windows)...") + windows.backup_app_data(server, app_dir, log) + if server.app_db_type == "mssql": + windows.backup_mssql(server, app_dir, log) + + # ── TRASFERIMENTO SU QNAP ──────────────────────────────── + log(f"Invio backup su QNAP ({dest_cfg.dest_type})...") + if dest_cfg.dest_type in ("rsync", "qnap_rsync"): + bytes_sent = qnap.rsync_to_qnap(dest_cfg, tmp_dir, remote_subpath, log) + elif dest_cfg.dest_type == "qnap_api": + client = qnap.QNAPClient(dest_cfg) + client.login() + for fname in os.listdir(tmp_dir): + client.upload_file(os.path.join(tmp_dir, fname), + os.path.join(dest_cfg.base_path, remote_subpath), log) + client.logout() + bytes_sent = sum( + os.path.getsize(os.path.join(tmp_dir, f)) + for f in os.listdir(tmp_dir) + if os.path.isfile(os.path.join(tmp_dir, f)) + ) + else: + raise ValueError(f"Tipo destinazione sconosciuto: {dest_cfg.dest_type}") + + # ── RETENTION ──────────────────────────────────────────── + if dest_cfg.max_retention_days: + qnap.apply_retention( + dest_cfg, + os.path.join(dest_cfg.base_path, server.name), + dest_cfg.max_retention_days, + log + ) + + # ── SUCCESSO ───────────────────────────────────────────── + run.status = RunStatus.SUCCESS + run.size_bytes = bytes_sent + run.backup_path = remote_subpath + run.finished_at = datetime.now(timezone.utc) + job.last_run_at = run.finished_at + job.last_run_status = RunStatus.SUCCESS + db.commit() + log(f"Backup completato con successo ({bytes_sent:,} bytes)") + + except Exception as exc: + run.status = RunStatus.FAILED + run.finished_at = datetime.now(timezone.utc) + run.error_message = str(exc) + job.last_run_at = run.finished_at + job.last_run_status = RunStatus.FAILED + db.commit() + log(f"ERRORE: {exc}", "ERROR") + raise + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) + + return run diff --git a/app/backup/linux.py b/app/backup/linux.py new file mode 100644 index 0000000..6d2d935 --- /dev/null +++ b/app/backup/linux.py @@ -0,0 +1,110 @@ +"""Backup dati applicativi da server Linux (Abulafia) via SSH/rsync.""" +import json +import subprocess +import paramiko +from app.crypto import decrypt + + +def _get_ssh_client(server) -> paramiko.SSHClient: + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + connect_kwargs = dict( + hostname=server.ip_address, + port=server.ssh_port or 22, + username=server.username, + timeout=30, + ) + if server.ssh_key_path: + connect_kwargs["key_filename"] = server.ssh_key_path + else: + connect_kwargs["password"] = decrypt(server.password_enc) + client.connect(**connect_kwargs) + return client + + +def backup_app_data(server, dest_dir: str, log_fn=None) -> int: + """ + Copia via rsync i percorsi configurati in server.app_data_paths. + Ritorna il totale byte trasferiti. + """ + paths = json.loads(server.app_data_paths or "[]") + if not paths: + if log_fn: + log_fn("Nessun percorso dati configurato per questo server", level="WARNING") + return 0 + + total_bytes = 0 + user = server.username + host = server.ip_address + port = server.ssh_port or 22 + + for remote_path in paths: + if log_fn: + log_fn(f"rsync {user}@{host}:{remote_path} -> {dest_dir}") + cmd = [ + "rsync", "-avz", "--stats", + "-e", f"ssh -p {port} -o StrictHostKeyChecking=no", + ] + if server.ssh_key_path: + cmd = [ + "rsync", "-avz", "--stats", + "-e", f"ssh -p {port} -i {server.ssh_key_path} -o StrictHostKeyChecking=no", + ] + cmd += [f"{user}@{host}:{remote_path}", dest_dir] + + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError(f"rsync fallito: {result.stderr}") + + # Parsing bytes trasferiti dall'output rsync + for line in result.stdout.splitlines(): + if "Total transferred file size:" in line: + try: + total_bytes += int(line.split(":")[1].strip().split()[0].replace(",", "")) + except Exception: + pass + if log_fn: + log_fn(result.stdout[-500:] if len(result.stdout) > 500 else result.stdout) + + return total_bytes + + +def backup_database(server, dest_dir: str, log_fn=None) -> str: + """ + Esegue pg_dump / mysqldump sul server remoto via SSH e scarica il dump. + Ritorna il percorso locale del file dump. + """ + import os + db_type = (server.app_db_type or "").lower() + db_name = server.app_db_name + db_user = server.app_db_user + db_pass = decrypt(server.app_db_password_enc) if server.app_db_password_enc else "" + dump_file = f"/tmp/{db_name}_backup.sql" + + if db_type == "postgresql": + remote_cmd = f"PGPASSWORD='{db_pass}' pg_dump -U {db_user} {db_name} > {dump_file}" + elif db_type == "mysql": + remote_cmd = f"mysqldump -u {db_user} -p'{db_pass}' {db_name} > {dump_file}" + else: + if log_fn: + log_fn(f"Tipo DB '{db_type}' non supportato per dump automatico", level="WARNING") + return "" + + client = _get_ssh_client(server) + try: + if log_fn: + log_fn(f"Dump DB {db_type}:{db_name} sul server remoto...") + _, stdout, stderr = client.exec_command(remote_cmd) + stdout.channel.recv_exit_status() + + # Scarica il dump con SFTP + sftp = client.open_sftp() + local_dump = os.path.join(dest_dir, f"{db_name}_backup.sql") + sftp.get(dump_file, local_dump) + sftp.remove(dump_file) + sftp.close() + if log_fn: + log_fn(f"DB dump salvato in {local_dump}") + return local_dump + finally: + client.close() diff --git a/app/backup/qnap.py b/app/backup/qnap.py new file mode 100644 index 0000000..6697bdf --- /dev/null +++ b/app/backup/qnap.py @@ -0,0 +1,141 @@ +"""Trasferimento backup verso QNAP via rsync o API QTS.""" +import os +import subprocess +import requests +from app.crypto import decrypt + + +# ────────────────────────────────────────────── +# RSYNC (metodo principale) +# ────────────────────────────────────────────── + +def rsync_to_qnap(dest_cfg, local_path: str, remote_subpath: str, log_fn=None) -> int: + """ + Invia local_path al QNAP via rsync over SSH. + dest_cfg: BackupDestination model instance. + remote_subpath: es. "windows_gamma/2024-01-15" + Ritorna byte trasferiti. + """ + password = decrypt(dest_cfg.password_enc) if dest_cfg.password_enc else "" + port = dest_cfg.port or 22 + user = dest_cfg.username + host = dest_cfg.host + remote_path = os.path.join(dest_cfg.base_path, remote_subpath).replace("\\", "/") + + if log_fn: + log_fn(f"rsync → QNAP {host}:{remote_path}") + + env = os.environ.copy() + if password: + # Usa sshpass se disponibile, altrimenti chiave SSH + env["SSHPASS"] = password + ssh_cmd = f"sshpass -e ssh -p {port} -o StrictHostKeyChecking=no" + else: + ssh_cmd = f"ssh -p {port} -o StrictHostKeyChecking=no" + + cmd = [ + "rsync", "-avz", "--stats", "--mkpath", + "-e", ssh_cmd, + local_path + "/" if not local_path.endswith("/") else local_path, + f"{user}@{host}:{remote_path}/", + ] + + result = subprocess.run(cmd, capture_output=True, text=True, env=env) + if result.returncode != 0: + raise RuntimeError(f"rsync verso QNAP fallito: {result.stderr}") + + if log_fn: + log_fn(result.stdout[-500:] if len(result.stdout) > 500 else result.stdout) + + total_bytes = 0 + for line in result.stdout.splitlines(): + if "Total transferred file size:" in line: + try: + total_bytes = int(line.split(":")[1].strip().split()[0].replace(",", "")) + except Exception: + pass + return total_bytes + + +# ────────────────────────────────────────────── +# QNAP QTS API (metodo alternativo) +# ────────────────────────────────────────────── + +class QNAPClient: + def __init__(self, dest_cfg): + self.host = dest_cfg.host + self.port = dest_cfg.port or 8080 + self.base_url = f"http://{self.host}:{self.port}/cgi-bin/filemanager" + self.username = dest_cfg.username + self.password = decrypt(dest_cfg.password_enc) + self.sid = None + + def login(self): + url = f"http://{self.host}:{self.port}/cgi-bin/authLogin.cgi" + resp = requests.get(url, params={ + "user": self.username, + "pwd": self.password, + }) + resp.raise_for_status() + # QTS risponde con XML + import xml.etree.ElementTree as ET + root = ET.fromstring(resp.text) + auth_passed = root.findtext(".//authPassed") + if auth_passed != "1": + raise RuntimeError("Autenticazione QNAP fallita") + self.sid = root.findtext(".//authSid") + + def upload_file(self, local_file: str, remote_dir: str, log_fn=None): + if not self.sid: + self.login() + url = f"{self.base_url}/utilRequest.cgi" + filename = os.path.basename(local_file) + if log_fn: + log_fn(f"Upload {filename} su QNAP {remote_dir}...") + with open(local_file, "rb") as f: + resp = requests.post(url, params={ + "func": "upload", + "type": "standard", + "sid": self.sid, + "dest_path": remote_dir, + "overwrite": 1, + }, files={"file": (filename, f)}) + resp.raise_for_status() + + def logout(self): + if self.sid: + requests.get( + f"http://{self.host}:{self.port}/cgi-bin/authLogin.cgi", + params={"logout": 1, "sid": self.sid} + ) + + +def apply_retention(dest_cfg, remote_base: str, retention_days: int, log_fn=None): + """ + Rimuove backup più vecchi di retention_days giorni via SSH sul QNAP. + remote_base: percorso base sul QNAP (es. /backup/windows_gamma) + """ + if not retention_days: + return + password = decrypt(dest_cfg.password_enc) if dest_cfg.password_enc else "" + port = dest_cfg.port or 22 + user = dest_cfg.username + host = dest_cfg.host + + cmd_find = ( + f"find {remote_base} -maxdepth 1 -type d " + f"-mtime +{retention_days} -exec rm -rf {{}} \\;" + ) + env = os.environ.copy() + if password: + env["SSHPASS"] = password + ssh_prefix = ["sshpass", "-e", "ssh", "-p", str(port), "-o", "StrictHostKeyChecking=no"] + else: + ssh_prefix = ["ssh", "-p", str(port), "-o", "StrictHostKeyChecking=no"] + + result = subprocess.run( + ssh_prefix + [f"{user}@{host}", cmd_find], + capture_output=True, text=True, env=env + ) + if log_fn: + log_fn(f"Retention applicata: rimossi backup > {retention_days} giorni") diff --git a/app/backup/vmware.py b/app/backup/vmware.py new file mode 100644 index 0000000..da61377 --- /dev/null +++ b/app/backup/vmware.py @@ -0,0 +1,168 @@ +"""Backup VM tramite API ESXi (pyvmomi): snapshot + export OVF.""" +import os +import ssl +import time +import requests +from pyVim.connect import SmartConnect, Disconnect +from pyVmomi import vim +from app.crypto import decrypt + + +def _connect(host_cfg): + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + if not host_cfg.ssl_verify: + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + si = SmartConnect( + host=host_cfg.host, + port=host_cfg.port, + user=host_cfg.username, + pwd=decrypt(host_cfg.password_enc), + sslContext=context, + ) + return si + + +def _find_vm(si, vm_name: str): + content = si.RetrieveContent() + container = content.viewManager.CreateContainerView( + content.rootFolder, [vim.VirtualMachine], True + ) + for vm in container.view: + if vm.name == vm_name: + return vm + return None + + +def _wait_task(task, log_fn=None): + while task.info.state in (vim.TaskInfo.State.running, vim.TaskInfo.State.queued): + if log_fn: + log_fn(f"Task {task.info.descriptionId}: {task.info.state}") + time.sleep(5) + if task.info.state != vim.TaskInfo.State.success: + raise RuntimeError(f"Task fallito: {task.info.error.localizedMessage}") + + +def create_snapshot(host_cfg, vm_name: str, snapshot_name: str, log_fn=None): + """Crea uno snapshot della VM.""" + si = _connect(host_cfg) + try: + vm = _find_vm(si, vm_name) + if not vm: + raise ValueError(f"VM '{vm_name}' non trovata su ESXi") + task = vm.CreateSnapshot_Task( + name=snapshot_name, + description="Backup automatico", + memory=False, + quiesce=True, + ) + _wait_task(task, log_fn) + if log_fn: + log_fn(f"Snapshot '{snapshot_name}' creato su '{vm_name}'") + finally: + Disconnect(si) + + +def remove_snapshot(host_cfg, vm_name: str, snapshot_name: str, log_fn=None): + """Rimuove uno snapshot per nome.""" + si = _connect(host_cfg) + try: + vm = _find_vm(si, vm_name) + if not vm: + return + snap_tree = vm.snapshot.rootSnapshotList if vm.snapshot else [] + + def find_snap(tree, name): + for s in tree: + if s.name == name: + return s.snapshot + found = find_snap(s.childSnapshotList, name) + if found: + return found + return None + + snap = find_snap(snap_tree, snapshot_name) + if snap: + task = snap.RemoveSnapshot_Task(removeChildren=False) + _wait_task(task, log_fn) + if log_fn: + log_fn(f"Snapshot '{snapshot_name}' rimosso") + finally: + Disconnect(si) + + +def export_vm_ovf(host_cfg, vm_name: str, dest_dir: str, log_fn=None) -> str: + """ + Esporta la VM come OVF nella directory dest_dir. + Ritorna il percorso del file .ovf creato. + Usa l'API HTTPS di ESXi per scaricare i VMDK. + """ + si = _connect(host_cfg) + try: + vm = _find_vm(si, vm_name) + if not vm: + raise ValueError(f"VM '{vm_name}' non trovata su ESXi") + + content = si.RetrieveContent() + lease = vm.ExportVm() + + # Attendi che il lease sia pronto + while lease.state == vim.HttpNfcLease.State.initializing: + time.sleep(2) + if lease.state == vim.HttpNfcLease.State.error: + raise RuntimeError(f"Export lease error: {lease.error.localizedMessage}") + + os.makedirs(dest_dir, exist_ok=True) + base_url = f"https://{host_cfg.host}:{host_cfg.port}" + session = requests.Session() + session.verify = host_cfg.ssl_verify + # Autentica la sessione HTTP con il cookie di vSphere + session.cookies.update({"vmware_soap_session": si._stub.cookie.split('"')[1]}) + + total_bytes = sum(i.size for i in lease.info.deviceUrl if i.size) + downloaded = 0 + + for device in lease.info.deviceUrl: + filename = device.targetId or device.key.replace("/", "_") + url = device.url.replace("*", host_cfg.host) + if log_fn: + log_fn(f"Download {filename} da ESXi...") + dest_file = os.path.join(dest_dir, filename) + with session.get(url, stream=True) as r: + r.raise_for_status() + with open(dest_file, "wb") as f: + for chunk in r.iter_content(chunk_size=1024 * 1024): + f.write(chunk) + downloaded += len(chunk) + if total_bytes: + pct = int(downloaded * 100 / total_bytes) + lease.HttpNfcLeaseProgress(pct) + + lease.HttpNfcLeaseComplete() + if log_fn: + log_fn(f"Export VM '{vm_name}' completato in {dest_dir}") + return dest_dir + finally: + Disconnect(si) + + +def list_vms(host_cfg) -> list[dict]: + """Restituisce lista VM sull'host ESXi.""" + si = _connect(host_cfg) + try: + content = si.RetrieveContent() + container = content.viewManager.CreateContainerView( + content.rootFolder, [vim.VirtualMachine], True + ) + result = [] + for vm in container.view: + result.append({ + "name": vm.name, + "power_state": str(vm.runtime.powerState), + "guest_os": vm.config.guestFullName if vm.config else None, + "num_cpu": vm.config.hardware.numCPU if vm.config else None, + "memory_mb": vm.config.hardware.memoryMB if vm.config else None, + }) + return result + finally: + Disconnect(si) diff --git a/app/backup/windows.py b/app/backup/windows.py new file mode 100644 index 0000000..254979f --- /dev/null +++ b/app/backup/windows.py @@ -0,0 +1,126 @@ +"""Backup dati applicativi da server Windows (Gamma/TeamSystem) via WinRM.""" +import json +import os +import winrm +from app.crypto import decrypt + + +def _get_session(server) -> winrm.Session: + return winrm.Session( + target=f"http://{server.ip_address}:{server.winrm_port or 5985}/wsman", + auth=(server.username, decrypt(server.password_enc)), + transport="ntlm", + ) + + +def backup_app_data(server, dest_dir: str, log_fn=None) -> int: + """ + Copia i file da Windows usando robocopy via WinRM verso una share SMB + raggiungibile dal server Windows, oppure via WinRM download diretto. + Usa PowerShell + Compress-Archive per creare uno zip dei dati, poi + lo scarica con WinRM. + """ + paths = json.loads(server.app_data_paths or "[]") + if not paths: + if log_fn: + log_fn("Nessun percorso dati configurato", level="WARNING") + return 0 + + session = _get_session(server) + total_bytes = 0 + + for remote_path in paths: + safe_name = remote_path.replace(":", "").replace("\\", "_").replace("/", "_") + zip_remote = f"C:\\Windows\\Temp\\backup_{safe_name}.zip" + + ps_cmd = f""" + $source = '{remote_path}' + $dest = '{zip_remote}' + if (Test-Path $source) {{ + Compress-Archive -Path $source -DestinationPath $dest -Force + Write-Output "OK:$dest" + }} else {{ + Write-Output "NOTFOUND:$source" + }} + """ + if log_fn: + log_fn(f"Compressione {remote_path} su Windows...") + result = session.run_ps(ps_cmd) + output = result.std_out.decode().strip() + + if result.status_code != 0 or "NOTFOUND" in output: + if log_fn: + log_fn(f"Percorso non trovato o errore: {output}", level="WARNING") + continue + + # Scarica lo zip via WinRM (lettura chunk base64) + ps_download = f""" + $bytes = [System.IO.File]::ReadAllBytes('{zip_remote}') + [Convert]::ToBase64String($bytes) + """ + if log_fn: + log_fn(f"Download {zip_remote} dal server Windows...") + dl_result = session.run_ps(ps_download) + b64_data = dl_result.std_out.strip() + import base64 + zip_data = base64.b64decode(b64_data) + local_zip = os.path.join(dest_dir, f"backup_{safe_name}.zip") + with open(local_zip, "wb") as f: + f.write(zip_data) + total_bytes += len(zip_data) + + # Rimuovi zip temporaneo da Windows + session.run_ps(f"Remove-Item '{zip_remote}' -Force") + if log_fn: + log_fn(f"Salvato {local_zip} ({len(zip_data):,} bytes)") + + return total_bytes + + +def backup_mssql(server, dest_dir: str, log_fn=None) -> str: + """ + Esegue backup SQL Server via T-SQL (BACKUP DATABASE TO DISK). + Richiede che il path di destinazione sia accessibile dal servizio SQL Server. + """ + db_name = server.app_db_name + if not db_name: + return "" + + backup_path = f"C:\\Windows\\Temp\\{db_name}_backup.bak" + ps_cmd = f""" + $conn = New-Object System.Data.SqlClient.SqlConnection + $conn.ConnectionString = "Server=localhost;Database=master;Integrated Security=True;" + $conn.Open() + $cmd = $conn.CreateCommand() + $cmd.CommandText = "BACKUP DATABASE [{db_name}] TO DISK = N'{backup_path}' WITH FORMAT, STATS = 10" + $cmd.CommandTimeout = 3600 + $cmd.ExecuteNonQuery() + $conn.Close() + Write-Output "BACKUP_OK:{backup_path}" + """ + session = _get_session(server) + if log_fn: + log_fn(f"Backup SQL Server database '{db_name}'...") + result = session.run_ps(ps_cmd) + output = result.std_out.decode().strip() + + if result.status_code != 0 or "BACKUP_OK" not in output: + raise RuntimeError(f"Backup MSSQL fallito: {result.std_err.decode()}") + + # Scarica il .bak + ps_download = f""" + $bytes = [System.IO.File]::ReadAllBytes('{backup_path}') + [Convert]::ToBase64String($bytes) + """ + if log_fn: + log_fn(f"Download {backup_path}...") + dl_result = session.run_ps(ps_download) + import base64 + bak_data = base64.b64decode(dl_result.std_out.strip()) + local_bak = os.path.join(dest_dir, f"{db_name}_backup.bak") + with open(local_bak, "wb") as f: + f.write(bak_data) + session.run_ps(f"Remove-Item '{backup_path}' -Force") + if log_fn: + log_fn(f"Salvato {local_bak} ({len(bak_data):,} bytes)") + return local_bak diff --git a/app/crypto.py b/app/crypto.py new file mode 100644 index 0000000..81f79a7 --- /dev/null +++ b/app/crypto.py @@ -0,0 +1,31 @@ +"""Cifratura semplice per credenziali nel DB usando Fernet.""" +import os +import base64 +from cryptography.fernet import Fernet + +_KEY_ENV = "BACKUP_SECRET_KEY" + + +def _get_fernet() -> Fernet: + key = os.getenv(_KEY_ENV) + if not key: + # Genera e salva una chiave al primo avvio (solo sviluppo) + key = Fernet.generate_key().decode() + os.environ[_KEY_ENV] = key + key_file = ".secret_key" + if not os.path.exists(key_file): + with open(key_file, "w") as f: + f.write(key) + return Fernet(key.encode() if isinstance(key, str) else key) + + +def encrypt(plaintext: str) -> str: + if not plaintext: + return "" + return _get_fernet().encrypt(plaintext.encode()).decode() + + +def decrypt(ciphertext: str) -> str: + if not ciphertext: + return "" + return _get_fernet().decrypt(ciphertext.encode()).decode() diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..44fc5e3 --- /dev/null +++ b/app/database.py @@ -0,0 +1,28 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, sessionmaker +import os + +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./backup_all.db") + +engine = create_engine( + DATABASE_URL, + connect_args={"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}, +) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +class Base(DeclarativeBase): + pass + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def init_db(): + from app import models # noqa: F401 + Base.metadata.create_all(bind=engine) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..86acec1 --- /dev/null +++ b/app/main.py @@ -0,0 +1,79 @@ +from fastapi import FastAPI, Request +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from fastapi.responses import RedirectResponse +from contextlib import asynccontextmanager +import os + +from app.database import init_db +from app.api import servers, destinations, jobs, runs + +# Carica .secret_key se esiste (persistenza chiave tra riavvii) +_key_file = ".secret_key" +if os.path.exists(_key_file): + with open(_key_file) as f: + os.environ.setdefault("BACKUP_SECRET_KEY", f.read().strip()) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + init_db() + from app.scheduler import start + start() + yield + from app.scheduler import stop + stop() + + +app = FastAPI(title="Backup-All CRI Catania", lifespan=lifespan) + +app.mount("/static", StaticFiles(directory="static"), name="static") +templates = Jinja2Templates(directory="templates") + +# ── API routers ─────────────────────────────────────── +app.include_router(servers.router) +app.include_router(destinations.router) +app.include_router(jobs.router) +app.include_router(runs.router) + + +# ── UI pages ────────────────────────────────────────── + +@app.get("/") +async def home(): + return RedirectResponse(url="/dashboard") + + +@app.get("/dashboard") +async def dashboard(request: Request): + return templates.TemplateResponse("dashboard.html", {"request": request}) + + +@app.get("/wizard/server") +async def wizard_server(request: Request): + return templates.TemplateResponse("wizard_server.html", {"request": request}) + + +@app.get("/wizard/destination") +async def wizard_destination(request: Request): + return templates.TemplateResponse("wizard_destination.html", {"request": request}) + + +@app.get("/wizard/job") +async def wizard_job(request: Request): + return templates.TemplateResponse("wizard_job.html", {"request": request}) + + +@app.get("/jobs") +async def jobs_page(request: Request): + return templates.TemplateResponse("jobs.html", {"request": request}) + + +@app.get("/history") +async def history_page(request: Request): + return templates.TemplateResponse("history.html", {"request": request}) + + +@app.get("/logs/{run_id}") +async def logs_page(run_id: int, request: Request): + return templates.TemplateResponse("logs.html", {"request": request, "run_id": run_id}) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..8c6ae56 --- /dev/null +++ b/app/models.py @@ -0,0 +1,153 @@ +from sqlalchemy import ( + Column, Integer, String, DateTime, Boolean, Text, ForeignKey, Enum as SAEnum +) +from sqlalchemy.orm import relationship +from datetime import datetime, timezone +import enum +from app.database import Base + + +class ServerType(str, enum.Enum): + WINDOWS = "windows" + LINUX = "linux" + + +class BackupType(str, enum.Enum): + VM_SNAPSHOT = "vm_snapshot" # ESXi snapshot + export OVF + APP_DATA = "app_data" # Solo dati applicativi + FULL = "full" # VM + dati applicativi + + +class JobStatus(str, enum.Enum): + ACTIVE = "active" + PAUSED = "paused" + DISABLED = "disabled" + + +class RunStatus(str, enum.Enum): + RUNNING = "running" + SUCCESS = "success" + FAILED = "failed" + PARTIAL = "partial" + + +class VMwareHost(Base): + """Configurazione host ESXi""" + __tablename__ = "vmware_hosts" + + id = Column(Integer, primary_key=True) + name = Column(String(100), nullable=False) + host = Column(String(255), nullable=False) + port = Column(Integer, default=443) + username = Column(String(100), nullable=False) + password_enc = Column(Text, nullable=False) # cifrato + ssl_verify = Column(Boolean, default=False) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + + servers = relationship("Server", back_populates="vmware_host") + + +class Server(Base): + """VM/server da cui fare backup""" + __tablename__ = "servers" + + id = Column(Integer, primary_key=True) + name = Column(String(100), nullable=False, unique=True) + description = Column(Text) + server_type = Column(SAEnum(ServerType), nullable=False) + ip_address = Column(String(45), nullable=False) + vm_name = Column(String(255)) # nome VM su ESXi + vmware_host_id = Column(Integer, ForeignKey("vmware_hosts.id")) + ssh_port = Column(Integer, default=22) # Linux + winrm_port = Column(Integer, default=5985) # Windows + username = Column(String(100)) + password_enc = Column(Text) + ssh_key_path = Column(Text) # percorso chiave privata SSH + app_name = Column(String(100)) # es. "gamma", "abulafia" + app_data_paths = Column(Text) # JSON array di percorsi + app_db_type = Column(String(50)) # "mssql", "postgresql", "mysql", ecc. + app_db_name = Column(String(100)) + app_db_user = Column(String(100)) + app_db_password_enc = Column(Text) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + + vmware_host = relationship("VMwareHost", back_populates="servers") + backup_jobs = relationship("BackupJob", back_populates="server") + + +class BackupDestination(Base): + """Destinazione backup (QNAP o altro)""" + __tablename__ = "backup_destinations" + + id = Column(Integer, primary_key=True) + name = Column(String(100), nullable=False) + description = Column(Text) + dest_type = Column(String(50), nullable=False) # "rsync", "smb", "qnap_api" + host = Column(String(255), nullable=False) + port = Column(Integer) + username = Column(String(100)) + password_enc = Column(Text) + base_path = Column(String(500), nullable=False) # percorso radice sul QNAP + rsync_module = Column(String(100)) # modulo rsync se usato + max_retention_days = Column(Integer, default=30) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + + backup_jobs = relationship("BackupJob", back_populates="destination") + + +class BackupJob(Base): + """Job di backup: collega server, destinazione, tipo e schedule""" + __tablename__ = "backup_jobs" + + id = Column(Integer, primary_key=True) + name = Column(String(100), nullable=False) + description = Column(Text) + server_id = Column(Integer, ForeignKey("servers.id"), nullable=False) + destination_id = Column(Integer, ForeignKey("backup_destinations.id"), nullable=False) + backup_type = Column(SAEnum(BackupType), nullable=False) + status = Column(SAEnum(JobStatus), default=JobStatus.ACTIVE) + cron_expression = Column(String(100), nullable=False) # es. "0 2 * * *" + retention_copies = Column(Integer, default=7) + compression = Column(Boolean, default=True) + notify_email = Column(String(255)) + last_run_at = Column(DateTime) + last_run_status = Column(SAEnum(RunStatus)) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + + server = relationship("Server", back_populates="backup_jobs") + destination = relationship("BackupDestination", back_populates="backup_jobs") + runs = relationship("BackupRun", back_populates="job", order_by="BackupRun.started_at.desc()") + + +class BackupRun(Base): + """Singola esecuzione di un job""" + __tablename__ = "backup_runs" + + id = Column(Integer, primary_key=True) + job_id = Column(Integer, ForeignKey("backup_jobs.id"), nullable=False) + status = Column(SAEnum(RunStatus), default=RunStatus.RUNNING) + started_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + finished_at = Column(DateTime) + size_bytes = Column(Integer) # dimensione backup + backup_path = Column(Text) # percorso finale sul QNAP + error_message = Column(Text) + triggered_by = Column(String(50), default="scheduler") # "scheduler" o "manual" + + job = relationship("BackupJob", back_populates="runs") + logs = relationship("BackupLog", back_populates="run", order_by="BackupLog.timestamp") + + +class BackupLog(Base): + """Log dettagliato di ogni run""" + __tablename__ = "backup_logs" + + id = Column(Integer, primary_key=True) + run_id = Column(Integer, ForeignKey("backup_runs.id"), nullable=False) + timestamp = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + level = Column(String(10), default="INFO") # INFO, WARNING, ERROR + message = Column(Text, nullable=False) + + run = relationship("BackupRun", back_populates="logs") diff --git a/app/scheduler.py b/app/scheduler.py new file mode 100644 index 0000000..a2129c4 --- /dev/null +++ b/app/scheduler.py @@ -0,0 +1,60 @@ +"""APScheduler: carica i job attivi dal DB e li schedula con espressione cron.""" +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from sqlalchemy.orm import Session + +from app.database import SessionLocal +from app.models import BackupJob, JobStatus + +scheduler = BackgroundScheduler(timezone="Europe/Rome") + + +def _execute_job(job_id: int): + from app.backup.engine import run_job + db: Session = SessionLocal() + try: + run_job(job_id, db, triggered_by="scheduler") + except Exception as e: + print(f"[SCHEDULER] Job {job_id} fallito: {e}") + finally: + db.close() + + +def load_jobs_from_db(): + """Ricarica tutti i job attivi dal database nello scheduler.""" + db: Session = SessionLocal() + try: + jobs = db.query(BackupJob).filter(BackupJob.status == JobStatus.ACTIVE).all() + # Rimuovi job esistenti e ricarica + for apj in scheduler.get_jobs(): + apj.remove() + for job in jobs: + parts = job.cron_expression.split() + if len(parts) == 5: + minute, hour, day, month, day_of_week = parts + else: + continue + scheduler.add_job( + _execute_job, + trigger=CronTrigger( + minute=minute, hour=hour, + day=day, month=month, day_of_week=day_of_week, + timezone="Europe/Rome", + ), + args=[job.id], + id=f"job_{job.id}", + replace_existing=True, + name=job.name, + ) + print(f"[SCHEDULER] {len(jobs)} job caricati") + finally: + db.close() + + +def start(): + scheduler.start() + load_jobs_from_db() + + +def stop(): + scheduler.shutdown(wait=False) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..98848b3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,15 @@ +fastapi==0.111.0 +uvicorn[standard]==0.29.0 +sqlalchemy==2.0.30 +alembic==1.13.1 +pyvmomi==8.0.2.0.1 +paramiko==3.4.0 +pywinrm==0.4.3 +requests==2.32.2 +apscheduler==3.10.4 +jinja2==3.1.4 +python-multipart==0.0.9 +aiofiles==23.2.1 +cryptography==42.0.8 +smbprotocol==1.13.0 +python-dotenv==1.0.1 diff --git a/run.py b/run.py new file mode 100644 index 0000000..6ad29ea --- /dev/null +++ b/run.py @@ -0,0 +1,15 @@ +import uvicorn +import os + +if __name__ == "__main__": + # Carica .secret_key se esiste + if os.path.exists(".secret_key"): + with open(".secret_key") as f: + os.environ.setdefault("BACKUP_SECRET_KEY", f.read().strip()) + + uvicorn.run( + "app.main:app", + host="0.0.0.0", + port=int(os.getenv("PORT", 8000)), + reload=os.getenv("DEV", "false").lower() == "true", + ) diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..779e3e1 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,99 @@ + + + + + + {% block title %}Backup-All{% endblock %} — CRI Catania + + + + + + + + +
+ + + + +
+ {% block content %}{% endblock %} +
+
+ + + + {% block scripts %}{% endblock %} + + diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..c77fb65 --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,148 @@ +{% extends "base.html" %} +{% block title %}Dashboard{% endblock %} + +{% block content %} +
+

Dashboard

+ +
+ + +
+
+
+
+ +
Server configurati
+
+
+
+
+
+
+ +
Job attivi
+
+
+
+
+
+
+ +
Backup riusciti (7gg)
+
+
+
+
+
+
+ +
Backup falliti (7gg)
+
+
+
+
+ +
+ +
+
+
+ Job di Backup + Nuovo +
+
+
+ + + + + +
NomeServerTipoProssimaUltimo
Caricamento…
+
+
+
+
+ + +
+
+
Ultime esecuzioni
+
+
Caricamento…
+
+ +
+
+
+{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/templates/history.html b/templates/history.html new file mode 100644 index 0000000..8344ba2 --- /dev/null +++ b/templates/history.html @@ -0,0 +1,76 @@ +{% extends "base.html" %} +{% block title %}Storico Backup{% endblock %} + +{% block content %} +
+

Storico Esecuzioni

+ +
+ +
+
+ + + + + + +
JobAvviatoDurataDimensionePercorsoStatoAvviato da
Caricamento…
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/jobs.html b/templates/jobs.html new file mode 100644 index 0000000..904ce41 --- /dev/null +++ b/templates/jobs.html @@ -0,0 +1,84 @@ +{% extends "base.html" %} +{% block title %}Job di Backup{% endblock %} + +{% block content %} +
+

Job di Backup

+ Nuovo Job +
+ +
+
+ + + + + + +
NomeServerDestinazioneTipoScheduleStatoUltimo runAzioni
Caricamento…
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/logs.html b/templates/logs.html new file mode 100644 index 0000000..904885c --- /dev/null +++ b/templates/logs.html @@ -0,0 +1,80 @@ +{% extends "base.html" %} +{% block title %}Log Esecuzione #{{ run_id }}{% endblock %} + +{% block content %} +
+
Log Esecuzione #{{ run_id }}
+
+ + Torna allo storico +
+
+ + +
+
+
Job
+
Avviato
+
Durata
+
Dimensione
+
Percorso
+
Stato
+
+
+ + +
Caricamento log…
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/wizard_destination.html b/templates/wizard_destination.html new file mode 100644 index 0000000..9e20a10 --- /dev/null +++ b/templates/wizard_destination.html @@ -0,0 +1,165 @@ +{% extends "base.html" %} +{% block title %}Aggiungi Destinazione QNAP{% endblock %} + +{% block content %} +
+
+
+
+
Configura destinazione QNAP
+
+
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+ +
rsync over SSH
+ Consigliato — efficiente e sicuro +
+
+
+
+
+
+ +
API QTS
+ Interfaccia nativa QNAP +
+
+
+
+
+ +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + + +
+ + +
Tutti i backup verranno salvati in questa cartella.
+
+ +
+ + +
Backup più vecchi di questo numero di giorni vengono eliminati automaticamente.
+
+ +
+ + +
+
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/wizard_job.html b/templates/wizard_job.html new file mode 100644 index 0000000..87c261f --- /dev/null +++ b/templates/wizard_job.html @@ -0,0 +1,158 @@ +{% extends "base.html" %} +{% block title %}Nuovo Job di Backup{% endblock %} + +{% block content %} +
+
+
+
+
Crea Job di Backup
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
Schedulazione
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ Espressione cron: + 0 2 * * * + Ogni notte alle 02:00 +
+
+
+ +
+
+ + +
+
+ + +
+
+ +
+ +
+
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/wizard_server.html b/templates/wizard_server.html new file mode 100644 index 0000000..1b7228d --- /dev/null +++ b/templates/wizard_server.html @@ -0,0 +1,352 @@ +{% extends "base.html" %} +{% block title %}Aggiungi Server{% endblock %} + +{% block content %} +
+
+
+
+
Configura Server / VM
+
+
+ +
+
1
Tipo server
+
2
Connessione
+
3
VMware
+
4
Applicazione
+
+ +
+ +
+
Che tipo di server vuoi aggiungere?
+
+
+
+
+ +
Windows Server
+ Gamma / TeamSystem +
+
+
+
+
+
+ +
Linux / Debian
+ Abulafia +
+
+
+
+
+ + +
+
+ + +
+
+ + + + + + + + + + + +
+ + + +
+
+
+
+
+
+ + + +{% endblock %} + +{% block scripts %} + +{% endblock %} From 362f3e35bf1f53ff03ec5511343874108925c302 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 13:10:42 +0000 Subject: [PATCH 02/74] =?UTF-8?q?feat:=20autenticazione,=20verifica=20inte?= =?UTF-8?q?grit=C3=A0,=20retention=20per=20copie,=20notifiche=20email,=20U?= =?UTF-8?q?I=20NAKIVO-style?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Autenticazione: login con bcrypt, sessione cookie (SessionMiddleware), primo avvio crea admin/changeme, wizard setup iniziale - Sicurezza: tutte le route UI/API protette da sessione, cambio password, gestione utenti (solo admin) - Verifica integrità: SHA-256 locale pre-trasferimento e remoto post-trasferimento via SSH, risultato salvato su BackupRun - Retention per copie: sostituisce quella a giorni, mantiene al massimo N backup per job (ls + rm via SSH) - Notifiche email: SMTP configurabile da UI, invio automatico a fine job con log dettagliato e stato integrità - UI dark NAKIVO-style: sidebar, topbar con user menu, stat cards, badge status colorati, login page - Nuovi campi Job: verify_integrity, notify_on_success, notify_on_failure - Pagina Impostazioni: SMTP con test, gestione utenti, info sistema https://claude.ai/code/session_01AfKVb7RehwV197JyXeeqab --- app/api/auth_routes.py | 119 +++++++++++ app/api/jobs.py | 11 +- app/api/runs.py | 2 + app/api/settings.py | 88 ++++++++ app/auth.py | 31 +++ app/backup/engine.py | 72 +++++-- app/backup/qnap.py | 63 ++++++ app/main.py | 80 +++++++- app/models.py | 83 +++++--- app/notifications.py | 87 ++++++++ requirements.txt | 2 + templates/base.html | 391 ++++++++++++++++++++++++++++-------- templates/dashboard.html | 225 +++++++++++++-------- templates/history.html | 89 ++++---- templates/jobs.html | 107 ++++++---- templates/login.html | 159 +++++++++++++++ templates/logs.html | 116 +++++++---- templates/settings.html | 222 ++++++++++++++++++++ templates/setup_wizard.html | 116 +++++++++++ templates/wizard_job.html | 244 ++++++++++++++-------- 20 files changed, 1899 insertions(+), 408 deletions(-) create mode 100644 app/api/auth_routes.py create mode 100644 app/api/settings.py create mode 100644 app/auth.py create mode 100644 app/notifications.py create mode 100644 templates/login.html create mode 100644 templates/settings.html create mode 100644 templates/setup_wizard.html diff --git a/app/api/auth_routes.py b/app/api/auth_routes.py new file mode 100644 index 0000000..4343f73 --- /dev/null +++ b/app/api/auth_routes.py @@ -0,0 +1,119 @@ +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import RedirectResponse +from pydantic import BaseModel +from sqlalchemy.orm import Session +from datetime import datetime, timezone + +from app.database import get_db +from app.auth import hash_password, verify_password, login_user, logout_user +from app.models import User + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +class LoginRequest(BaseModel): + username: str + password: str + + +class ChangePasswordRequest(BaseModel): + current_password: str + new_password: str + + +class CreateUserRequest(BaseModel): + username: str + email: str = "" + password: str + is_admin: bool = False + + +@router.post("/login") +def login(data: LoginRequest, request: Request, db: Session = Depends(get_db)): + user = db.query(User).filter_by(username=data.username, is_active=True).first() + if not user or not verify_password(data.password, user.hashed_password): + raise HTTPException(401, "Credenziali non valide") + login_user(request, user.id) + user.last_login_at = datetime.now(timezone.utc) + db.commit() + return {"ok": True, "username": user.username, "is_admin": user.is_admin} + + +@router.post("/logout") +def logout(request: Request): + logout_user(request) + return {"ok": True} + + +@router.get("/me") +def me(request: Request, db: Session = Depends(get_db)): + uid = request.session.get("user_id") + if not uid: + raise HTTPException(401, "Non autenticato") + user = db.get(User, uid) + if not user: + raise HTTPException(401, "Utente non trovato") + return {"id": user.id, "username": user.username, "email": user.email, + "is_admin": user.is_admin, "last_login_at": user.last_login_at} + + +@router.post("/change-password") +def change_password(data: ChangePasswordRequest, request: Request, db: Session = Depends(get_db)): + uid = request.session.get("user_id") + if not uid: + raise HTTPException(401, "Non autenticato") + user = db.get(User, uid) + if not verify_password(data.current_password, user.hashed_password): + raise HTTPException(400, "Password attuale non corretta") + user.hashed_password = hash_password(data.new_password) + db.commit() + return {"ok": True} + + +@router.get("/users") +def list_users(request: Request, db: Session = Depends(get_db)): + uid = request.session.get("user_id") + if not uid: + raise HTTPException(401, "Non autenticato") + caller = db.get(User, uid) + if not caller or not caller.is_admin: + raise HTTPException(403, "Solo gli amministratori possono gestire gli utenti") + users = db.query(User).all() + return [{"id": u.id, "username": u.username, "email": u.email, + "is_admin": u.is_admin, "is_active": u.is_active, + "last_login_at": u.last_login_at} for u in users] + + +@router.post("/users", status_code=201) +def create_user(data: CreateUserRequest, request: Request, db: Session = Depends(get_db)): + uid = request.session.get("user_id") + if not uid: + raise HTTPException(401, "Non autenticato") + caller = db.get(User, uid) + if not caller or not caller.is_admin: + raise HTTPException(403, "Solo gli amministratori possono creare utenti") + if db.query(User).filter_by(username=data.username).first(): + raise HTTPException(400, "Username già esistente") + user = User(username=data.username, email=data.email, + hashed_password=hash_password(data.password), is_admin=data.is_admin) + db.add(user) + db.commit() + return {"id": user.id, "username": user.username} + + +@router.delete("/users/{user_id}") +def delete_user(user_id: int, request: Request, db: Session = Depends(get_db)): + uid = request.session.get("user_id") + if not uid: + raise HTTPException(401, "Non autenticato") + caller = db.get(User, uid) + if not caller or not caller.is_admin: + raise HTTPException(403, "Solo gli amministratori possono eliminare utenti") + if user_id == uid: + raise HTTPException(400, "Non puoi eliminare te stesso") + user = db.get(User, user_id) + if not user: + raise HTTPException(404, "Utente non trovato") + db.delete(user) + db.commit() + return {"ok": True} diff --git a/app/api/jobs.py b/app/api/jobs.py index 15957fd..0251e4a 100644 --- a/app/api/jobs.py +++ b/app/api/jobs.py @@ -19,6 +19,9 @@ class JobCreate(BaseModel): retention_copies: int = 7 compression: bool = True notify_email: Optional[str] = None + notify_on_success: bool = False + notify_on_failure: bool = True + verify_integrity: bool = True @router.get("") @@ -34,7 +37,8 @@ def create_job(data: JobCreate, db: Session = Depends(get_db)): server_id=data.server_id, destination_id=data.destination_id, backup_type=data.backup_type, cron_expression=data.cron_expression, retention_copies=data.retention_copies, compression=data.compression, - notify_email=data.notify_email, + notify_email=data.notify_email, notify_on_success=data.notify_on_success, + notify_on_failure=data.notify_on_failure, verify_integrity=data.verify_integrity, ) db.add(job) db.commit() @@ -99,6 +103,11 @@ def _serialize(j: BackupJob) -> dict: "backup_type": j.backup_type, "status": j.status, "cron_expression": j.cron_expression, "retention_copies": j.retention_copies, + "compression": j.compression, + "notify_email": j.notify_email, + "notify_on_success": j.notify_on_success, + "notify_on_failure": j.notify_on_failure, + "verify_integrity": j.verify_integrity, "last_run_at": j.last_run_at.isoformat() if j.last_run_at else None, "last_run_status": j.last_run_status, } diff --git a/app/api/runs.py b/app/api/runs.py index d63ec72..142cb90 100644 --- a/app/api/runs.py +++ b/app/api/runs.py @@ -43,4 +43,6 @@ def _serialize(r: BackupRun) -> dict: "backup_path": r.backup_path, "error_message": r.error_message, "triggered_by": r.triggered_by, + "checksum_sha256": r.checksum_sha256, + "integrity_verified": r.integrity_verified, } diff --git a/app/api/settings.py b/app/api/settings.py new file mode 100644 index 0000000..b4c2ac4 --- /dev/null +++ b/app/api/settings.py @@ -0,0 +1,88 @@ +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel +from typing import Optional +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models import SystemSettings + +router = APIRouter(prefix="/api/settings", tags=["settings"]) + +SMTP_KEYS = ["smtp_host", "smtp_port", "smtp_user", "smtp_password", + "smtp_from", "smtp_tls"] + + +class SmtpSettings(BaseModel): + smtp_host: str = "" + smtp_port: int = 587 + smtp_user: str = "" + smtp_password: str = "" + smtp_from: str = "" + smtp_tls: bool = True + + +def _require_admin(request: Request, db: Session): + from app.models import User + uid = request.session.get("user_id") + if not uid: + raise HTTPException(401, "Non autenticato") + user = db.get(User, uid) + if not user or not user.is_admin: + raise HTTPException(403, "Accesso riservato agli amministratori") + return user + + +def _get(db: Session, key: str) -> str: + s = db.query(SystemSettings).filter_by(key=key).first() + return s.value or "" if s else "" + + +def _set(db: Session, key: str, value: str): + s = db.query(SystemSettings).filter_by(key=key).first() + if s: + s.value = value + else: + db.add(SystemSettings(key=key, value=value)) + db.commit() + + +@router.get("/smtp") +def get_smtp(request: Request, db: Session = Depends(get_db)): + _require_admin(request, db) + return { + "smtp_host": _get(db, "smtp_host"), + "smtp_port": int(_get(db, "smtp_port") or 587), + "smtp_user": _get(db, "smtp_user"), + "smtp_password": "***" if _get(db, "smtp_password") else "", + "smtp_from": _get(db, "smtp_from"), + "smtp_tls": (_get(db, "smtp_tls") or "true").lower() == "true", + } + + +@router.put("/smtp") +def save_smtp(data: SmtpSettings, request: Request, db: Session = Depends(get_db)): + _require_admin(request, db) + _set(db, "smtp_host", data.smtp_host) + _set(db, "smtp_port", str(data.smtp_port)) + _set(db, "smtp_user", data.smtp_user) + if data.smtp_password and data.smtp_password != "***": + _set(db, "smtp_password", data.smtp_password) + _set(db, "smtp_from", data.smtp_from) + _set(db, "smtp_tls", "true" if data.smtp_tls else "false") + return {"ok": True} + + +@router.post("/smtp/test") +def test_smtp(request: Request, db: Session = Depends(get_db)): + _require_admin(request, db) + from app.models import User + uid = request.session.get("user_id") + user = db.get(User, uid) + email = user.email or "test@example.com" + from app.notifications import send_email + try: + send_email(db, email, "Test SMTP — Backup-All CRI Catania", + "Se ricevi questa email, la configurazione SMTP è corretta.") + return {"ok": True, "message": f"Email di test inviata a {email}"} + except Exception as e: + raise HTTPException(400, f"Invio email fallito: {e}") diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..1bacf0c --- /dev/null +++ b/app/auth.py @@ -0,0 +1,31 @@ +"""Autenticazione: hashing password e gestione sessione.""" +import bcrypt +from fastapi import Request, HTTPException + + +def hash_password(plain: str) -> str: + salt = bcrypt.gensalt() + return bcrypt.hashpw(plain.encode(), salt).decode() + + +def verify_password(plain: str, hashed: str) -> bool: + return bcrypt.checkpw(plain.encode(), hashed.encode()) + + +def get_session_user_id(request: Request) -> int | None: + return request.session.get("user_id") + + +def require_user(request: Request): + uid = get_session_user_id(request) + if not uid: + raise HTTPException(status_code=401, detail="Non autenticato") + return uid + + +def login_user(request: Request, user_id: int): + request.session["user_id"] = user_id + + +def logout_user(request: Request): + request.session.clear() diff --git a/app/backup/engine.py b/app/backup/engine.py index 9cb7afb..7bb9b01 100644 --- a/app/backup/engine.py +++ b/app/backup/engine.py @@ -1,4 +1,5 @@ """Orchestratore backup: coordina VMware, Linux/Windows e QNAP.""" +import hashlib import os import shutil import tempfile @@ -17,6 +18,24 @@ def _log(db: Session, run: BackupRun, message: str, level: str = "INFO"): print(f"[{level}] {message}") +def _sha256_dir(path: str) -> str: + """Calcola SHA-256 combinato di tutti i file in una directory.""" + h = hashlib.sha256() + for root, _, files in sorted(os.walk(path)): + for fname in sorted(files): + fpath = os.path.join(root, fname) + h.update(os.path.relpath(fpath, path).encode()) + with open(fpath, "rb") as f: + for chunk in iter(lambda: f.read(65536), b""): + h.update(chunk) + return h.hexdigest() + + +def _apply_count_retention(dest_cfg, server_name: str, max_copies: int, log_fn=None): + """Mantiene al massimo max_copies backup per job sul QNAP (per conteggio).""" + qnap.apply_count_retention(dest_cfg, server_name, max_copies, log_fn) + + def run_job(job_id: int, db: Session, triggered_by: str = "scheduler") -> BackupRun: job: BackupJob = db.get(BackupJob, job_id) if not job: @@ -60,17 +79,24 @@ def run_job(job_id: int, db: Session, triggered_by: str = "scheduler") -> Backup from app.models import ServerType if server.server_type == ServerType.LINUX: - log("Backup dati Abulafia (Linux)...") + log("Backup dati Linux...") linux.backup_app_data(server, app_dir, log) if server.app_db_type: linux.backup_database(server, app_dir, log) - elif server.server_type == ServerType.WINDOWS: - log("Backup dati Gamma/TeamSystem (Windows)...") + log("Backup dati Windows...") windows.backup_app_data(server, app_dir, log) if server.app_db_type == "mssql": windows.backup_mssql(server, app_dir, log) + # ── VERIFICA INTEGRITÀ (pre-trasferimento) ──────────────── + if job.verify_integrity: + log("Calcolo checksum SHA-256 del backup locale...") + local_checksum = _sha256_dir(tmp_dir) + run.checksum_sha256 = local_checksum + db.commit() + log(f"Checksum: {local_checksum[:16]}…") + # ── TRASFERIMENTO SU QNAP ──────────────────────────────── log(f"Invio backup su QNAP ({dest_cfg.dest_type})...") if dest_cfg.dest_type in ("rsync", "qnap_rsync"): @@ -90,14 +116,29 @@ def run_job(job_id: int, db: Session, triggered_by: str = "scheduler") -> Backup else: raise ValueError(f"Tipo destinazione sconosciuto: {dest_cfg.dest_type}") - # ── RETENTION ──────────────────────────────────────────── - if dest_cfg.max_retention_days: - qnap.apply_retention( - dest_cfg, - os.path.join(dest_cfg.base_path, server.name), - dest_cfg.max_retention_days, - log - ) + # ── VERIFICA INTEGRITÀ (post-trasferimento) ─────────────── + if job.verify_integrity and run.checksum_sha256: + log("Verifica integrità post-trasferimento via SSH...") + try: + remote_checksum = qnap.compute_remote_checksum( + dest_cfg, + os.path.join(dest_cfg.base_path, remote_subpath) + ) + if remote_checksum and remote_checksum == run.checksum_sha256: + run.integrity_verified = True + log("Integrità verificata: checksum corrispondente ✓") + else: + run.integrity_verified = False + log(f"ATTENZIONE: checksum non corrispondente! locale={run.checksum_sha256[:16]} remoto={str(remote_checksum)[:16]}", "WARNING") + except Exception as e: + log(f"Verifica integrità remota non disponibile: {e}", "WARNING") + run.integrity_verified = None + db.commit() + + # ── RETENTION (per numero copie) ────────────────────────── + if job.retention_copies and job.retention_copies > 0: + log(f"Applicazione retention: max {job.retention_copies} copie...") + _apply_count_retention(dest_cfg, server.name, job.retention_copies, log) # ── SUCCESSO ───────────────────────────────────────────── run.status = RunStatus.SUCCESS @@ -117,8 +158,15 @@ def run_job(job_id: int, db: Session, triggered_by: str = "scheduler") -> Backup job.last_run_status = RunStatus.FAILED db.commit() log(f"ERRORE: {exc}", "ERROR") - raise + finally: shutil.rmtree(tmp_dir, ignore_errors=True) + # ── NOTIFICA EMAIL ──────────────────────────────────────────── + try: + from app.notifications import notify_backup_result + notify_backup_result(db, job, run) + except Exception as e: + print(f"[WARN] Notifica email fallita: {e}") + return run diff --git a/app/backup/qnap.py b/app/backup/qnap.py index 6697bdf..59481e4 100644 --- a/app/backup/qnap.py +++ b/app/backup/qnap.py @@ -110,6 +110,69 @@ def logout(self): ) +def apply_count_retention(dest_cfg, server_name: str, max_copies: int, log_fn=None): + """ + Mantiene al massimo max_copies directory di backup sul QNAP per questo server. + Le directory sono ordinate per nome (timestamp), quindi le più vecchie vengono rimosse. + """ + if not max_copies or max_copies <= 0: + return + password = decrypt(dest_cfg.password_enc) if dest_cfg.password_enc else "" + port = dest_cfg.port or 22 + user = dest_cfg.username + host = dest_cfg.host + remote_base = os.path.join(dest_cfg.base_path, server_name).replace("\\", "/") + + env = os.environ.copy() + if password: + env["SSHPASS"] = password + ssh_prefix = ["sshpass", "-e", "ssh", "-p", str(port), "-o", "StrictHostKeyChecking=no"] + else: + ssh_prefix = ["ssh", "-p", str(port), "-o", "StrictHostKeyChecking=no"] + + # Elenca directory di backup ordinate per nome (formato timestamp YYYY-MM-DD_HH-MM-SS) + list_cmd = f"ls -1d {remote_base}/20*/ 2>/dev/null | sort" + result = subprocess.run( + ssh_prefix + [f"{user}@{host}", list_cmd], + capture_output=True, text=True, env=env + ) + dirs = [d.strip().rstrip("/") for d in result.stdout.splitlines() if d.strip()] + to_delete = dirs[:-max_copies] if len(dirs) > max_copies else [] + + for d in to_delete: + del_cmd = f"rm -rf {d}" + subprocess.run(ssh_prefix + [f"{user}@{host}", del_cmd], env=env, capture_output=True) + if log_fn: + log_fn(f"Retention: rimosso backup vecchio {d}") + + if log_fn and to_delete: + log_fn(f"Retention: mantenute {min(len(dirs), max_copies)} copie, rimosse {len(to_delete)}") + + +def compute_remote_checksum(dest_cfg, remote_path: str) -> str | None: + """Calcola SHA-256 di tutti i file in remote_path via SSH (richiede sha256sum sul QNAP).""" + password = decrypt(dest_cfg.password_enc) if dest_cfg.password_enc else "" + port = dest_cfg.port or 22 + user = dest_cfg.username + host = dest_cfg.host + remote_path = remote_path.replace("\\", "/") + + env = os.environ.copy() + if password: + env["SSHPASS"] = password + ssh_prefix = ["sshpass", "-e", "ssh", "-p", str(port), "-o", "StrictHostKeyChecking=no"] + else: + ssh_prefix = ["ssh", "-p", str(port), "-o", "StrictHostKeyChecking=no"] + + cmd = f"find {remote_path} -type f | sort | xargs sha256sum 2>/dev/null | sha256sum | awk '{{print $1}}'" + result = subprocess.run( + ssh_prefix + [f"{user}@{host}", cmd], + capture_output=True, text=True, env=env, timeout=120 + ) + checksum = result.stdout.strip() + return checksum if len(checksum) == 64 else None + + def apply_retention(dest_cfg, remote_base: str, retention_days: int, log_fn=None): """ Rimuove backup più vecchi di retention_days giorni via SSH sul QNAP. diff --git a/app/main.py b/app/main.py index 86acec1..6be870c 100644 --- a/app/main.py +++ b/app/main.py @@ -2,22 +2,28 @@ from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from fastapi.responses import RedirectResponse +from starlette.middleware.sessions import SessionMiddleware from contextlib import asynccontextmanager -import os +import os, secrets from app.database import init_db from app.api import servers, destinations, jobs, runs +from app.api import auth_routes, settings as settings_api -# Carica .secret_key se esiste (persistenza chiave tra riavvii) _key_file = ".secret_key" if os.path.exists(_key_file): with open(_key_file) as f: - os.environ.setdefault("BACKUP_SECRET_KEY", f.read().strip()) + _secret = f.read().strip() +else: + _secret = os.environ.get("BACKUP_SECRET_KEY") or secrets.token_hex(32) + with open(_key_file, "w") as f: + f.write(_secret) @asynccontextmanager async def lifespan(app: FastAPI): init_db() + _ensure_admin_exists() from app.scheduler import start start() yield @@ -25,7 +31,29 @@ async def lifespan(app: FastAPI): stop() +def _ensure_admin_exists(): + """Se non esiste nessun utente, crea l'admin di default al primo avvio.""" + from app.database import SessionLocal + from app.models import User + from app.auth import hash_password + db = SessionLocal() + try: + if not db.query(User).first(): + admin = User( + username="admin", + email="", + hashed_password=hash_password("changeme"), + is_admin=True, + ) + db.add(admin) + db.commit() + print("[INFO] Utente admin creato (password: changeme). Cambiala subito!") + finally: + db.close() + + app = FastAPI(title="Backup-All CRI Catania", lifespan=lifespan) +app.add_middleware(SessionMiddleware, secret_key=_secret, https_only=False, max_age=86400) app.mount("/static", StaticFiles(directory="static"), name="static") templates = Jinja2Templates(directory="templates") @@ -35,45 +63,89 @@ async def lifespan(app: FastAPI): app.include_router(destinations.router) app.include_router(jobs.router) app.include_router(runs.router) +app.include_router(auth_routes.router) +app.include_router(settings_api.router) + + +# ── Auth middleware per pagine UI ───────────────────── +def _check_session(request: Request): + """Restituisce user_id dalla sessione o None.""" + return request.session.get("user_id") # ── UI pages ────────────────────────────────────────── @app.get("/") -async def home(): +async def home(request: Request): + if not _check_session(request): + return RedirectResponse(url="/login") return RedirectResponse(url="/dashboard") +@app.get("/login") +async def login_page(request: Request): + if _check_session(request): + return RedirectResponse(url="/dashboard") + return templates.TemplateResponse("login.html", {"request": request}) + + +@app.get("/setup") +async def setup_page(request: Request): + """Wizard primo avvio.""" + return templates.TemplateResponse("setup_wizard.html", {"request": request}) + + @app.get("/dashboard") async def dashboard(request: Request): + if not _check_session(request): + return RedirectResponse(url="/login") return templates.TemplateResponse("dashboard.html", {"request": request}) @app.get("/wizard/server") async def wizard_server(request: Request): + if not _check_session(request): + return RedirectResponse(url="/login") return templates.TemplateResponse("wizard_server.html", {"request": request}) @app.get("/wizard/destination") async def wizard_destination(request: Request): + if not _check_session(request): + return RedirectResponse(url="/login") return templates.TemplateResponse("wizard_destination.html", {"request": request}) @app.get("/wizard/job") async def wizard_job(request: Request): + if not _check_session(request): + return RedirectResponse(url="/login") return templates.TemplateResponse("wizard_job.html", {"request": request}) @app.get("/jobs") async def jobs_page(request: Request): + if not _check_session(request): + return RedirectResponse(url="/login") return templates.TemplateResponse("jobs.html", {"request": request}) @app.get("/history") async def history_page(request: Request): + if not _check_session(request): + return RedirectResponse(url="/login") return templates.TemplateResponse("history.html", {"request": request}) @app.get("/logs/{run_id}") async def logs_page(run_id: int, request: Request): + if not _check_session(request): + return RedirectResponse(url="/login") return templates.TemplateResponse("logs.html", {"request": request, "run_id": run_id}) + + +@app.get("/settings") +async def settings_page(request: Request): + if not _check_session(request): + return RedirectResponse(url="/login") + return templates.TemplateResponse("settings.html", {"request": request}) diff --git a/app/models.py b/app/models.py index 8c6ae56..a1666c2 100644 --- a/app/models.py +++ b/app/models.py @@ -13,9 +13,9 @@ class ServerType(str, enum.Enum): class BackupType(str, enum.Enum): - VM_SNAPSHOT = "vm_snapshot" # ESXi snapshot + export OVF - APP_DATA = "app_data" # Solo dati applicativi - FULL = "full" # VM + dati applicativi + VM_SNAPSHOT = "vm_snapshot" + APP_DATA = "app_data" + FULL = "full" class JobStatus(str, enum.Enum): @@ -31,8 +31,32 @@ class RunStatus(str, enum.Enum): PARTIAL = "partial" +class User(Base): + """Utente dell'applicazione""" + __tablename__ = "users" + + id = Column(Integer, primary_key=True) + username = Column(String(80), nullable=False, unique=True) + email = Column(String(255)) + hashed_password = Column(Text, nullable=False) + is_admin = Column(Boolean, default=False) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + last_login_at = Column(DateTime) + + +class SystemSettings(Base): + """Impostazioni di sistema (chiave/valore)""" + __tablename__ = "system_settings" + + id = Column(Integer, primary_key=True) + key = Column(String(100), nullable=False, unique=True) + value = Column(Text) + updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc)) + + class VMwareHost(Base): - """Configurazione host ESXi""" __tablename__ = "vmware_hosts" id = Column(Integer, primary_key=True) @@ -40,7 +64,7 @@ class VMwareHost(Base): host = Column(String(255), nullable=False) port = Column(Integer, default=443) username = Column(String(100), nullable=False) - password_enc = Column(Text, nullable=False) # cifrato + password_enc = Column(Text, nullable=False) ssl_verify = Column(Boolean, default=False) created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) @@ -48,7 +72,6 @@ class VMwareHost(Base): class Server(Base): - """VM/server da cui fare backup""" __tablename__ = "servers" id = Column(Integer, primary_key=True) @@ -56,16 +79,16 @@ class Server(Base): description = Column(Text) server_type = Column(SAEnum(ServerType), nullable=False) ip_address = Column(String(45), nullable=False) - vm_name = Column(String(255)) # nome VM su ESXi + vm_name = Column(String(255)) vmware_host_id = Column(Integer, ForeignKey("vmware_hosts.id")) - ssh_port = Column(Integer, default=22) # Linux - winrm_port = Column(Integer, default=5985) # Windows + ssh_port = Column(Integer, default=22) + winrm_port = Column(Integer, default=5985) username = Column(String(100)) password_enc = Column(Text) - ssh_key_path = Column(Text) # percorso chiave privata SSH - app_name = Column(String(100)) # es. "gamma", "abulafia" - app_data_paths = Column(Text) # JSON array di percorsi - app_db_type = Column(String(50)) # "mssql", "postgresql", "mysql", ecc. + ssh_key_path = Column(Text) + app_name = Column(String(100)) + app_data_paths = Column(Text) + app_db_type = Column(String(50)) app_db_name = Column(String(100)) app_db_user = Column(String(100)) app_db_password_enc = Column(Text) @@ -77,19 +100,18 @@ class Server(Base): class BackupDestination(Base): - """Destinazione backup (QNAP o altro)""" __tablename__ = "backup_destinations" id = Column(Integer, primary_key=True) name = Column(String(100), nullable=False) description = Column(Text) - dest_type = Column(String(50), nullable=False) # "rsync", "smb", "qnap_api" + dest_type = Column(String(50), nullable=False) host = Column(String(255), nullable=False) port = Column(Integer) username = Column(String(100)) password_enc = Column(Text) - base_path = Column(String(500), nullable=False) # percorso radice sul QNAP - rsync_module = Column(String(100)) # modulo rsync se usato + base_path = Column(String(500), nullable=False) + rsync_module = Column(String(100)) max_retention_days = Column(Integer, default=30) is_active = Column(Boolean, default=True) created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) @@ -98,7 +120,6 @@ class BackupDestination(Base): class BackupJob(Base): - """Job di backup: collega server, destinazione, tipo e schedule""" __tablename__ = "backup_jobs" id = Column(Integer, primary_key=True) @@ -108,22 +129,26 @@ class BackupJob(Base): destination_id = Column(Integer, ForeignKey("backup_destinations.id"), nullable=False) backup_type = Column(SAEnum(BackupType), nullable=False) status = Column(SAEnum(JobStatus), default=JobStatus.ACTIVE) - cron_expression = Column(String(100), nullable=False) # es. "0 2 * * *" + cron_expression = Column(String(100), nullable=False) retention_copies = Column(Integer, default=7) compression = Column(Boolean, default=True) notify_email = Column(String(255)) + notify_on_success = Column(Boolean, default=False) + notify_on_failure = Column(Boolean, default=True) + verify_integrity = Column(Boolean, default=True) last_run_at = Column(DateTime) last_run_status = Column(SAEnum(RunStatus)) created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) - updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc)) server = relationship("Server", back_populates="backup_jobs") destination = relationship("BackupDestination", back_populates="backup_jobs") - runs = relationship("BackupRun", back_populates="job", order_by="BackupRun.started_at.desc()") + runs = relationship("BackupRun", back_populates="job", + order_by="BackupRun.started_at.desc()") class BackupRun(Base): - """Singola esecuzione di un job""" __tablename__ = "backup_runs" id = Column(Integer, primary_key=True) @@ -131,23 +156,25 @@ class BackupRun(Base): status = Column(SAEnum(RunStatus), default=RunStatus.RUNNING) started_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) finished_at = Column(DateTime) - size_bytes = Column(Integer) # dimensione backup - backup_path = Column(Text) # percorso finale sul QNAP + size_bytes = Column(Integer) + backup_path = Column(Text) error_message = Column(Text) - triggered_by = Column(String(50), default="scheduler") # "scheduler" o "manual" + triggered_by = Column(String(50), default="scheduler") + checksum_sha256 = Column(String(64)) + integrity_verified = Column(Boolean) job = relationship("BackupJob", back_populates="runs") - logs = relationship("BackupLog", back_populates="run", order_by="BackupLog.timestamp") + logs = relationship("BackupLog", back_populates="run", + order_by="BackupLog.timestamp") class BackupLog(Base): - """Log dettagliato di ogni run""" __tablename__ = "backup_logs" id = Column(Integer, primary_key=True) run_id = Column(Integer, ForeignKey("backup_runs.id"), nullable=False) timestamp = Column(DateTime, default=lambda: datetime.now(timezone.utc)) - level = Column(String(10), default="INFO") # INFO, WARNING, ERROR + level = Column(String(10), default="INFO") message = Column(Text, nullable=False) run = relationship("BackupRun", back_populates="logs") diff --git a/app/notifications.py b/app/notifications.py new file mode 100644 index 0000000..36de3a4 --- /dev/null +++ b/app/notifications.py @@ -0,0 +1,87 @@ +"""Notifiche email via SMTP.""" +import smtplib +import ssl +from email.message import EmailMessage +from typing import Optional + +from sqlalchemy.orm import Session + + +def _get_setting(db: Session, key: str) -> Optional[str]: + from app.models import SystemSettings + s = db.query(SystemSettings).filter_by(key=key).first() + return s.value if s else None + + +def send_email(db: Session, to: str, subject: str, body: str): + """Invia email usando le impostazioni SMTP salvate nel DB.""" + host = _get_setting(db, "smtp_host") + if not host: + return # SMTP non configurato + port = int(_get_setting(db, "smtp_port") or 587) + user = _get_setting(db, "smtp_user") or "" + password = _get_setting(db, "smtp_password") or "" + from_addr = _get_setting(db, "smtp_from") or user + use_tls = (_get_setting(db, "smtp_tls") or "true").lower() == "true" + + msg = EmailMessage() + msg["Subject"] = subject + msg["From"] = from_addr + msg["To"] = to + msg.set_content(body) + + ctx = ssl.create_default_context() if use_tls else None + try: + if use_tls: + with smtplib.SMTP(host, port, timeout=10) as s: + s.starttls(context=ctx) + if user: + s.login(user, password) + s.send_message(msg) + else: + with smtplib.SMTP(host, port, timeout=10) as s: + if user: + s.login(user, password) + s.send_message(msg) + except Exception as exc: + print(f"[WARN] Email non inviata a {to}: {exc}") + + +def notify_backup_result(db: Session, job, run): + """Invia notifica email al termine di un backup se configurata.""" + if not job.notify_email: + return + if run.status.value == "success" and not job.notify_on_success: + return + if run.status.value == "failed" and not job.notify_on_failure: + return + + status_label = {"success": "COMPLETATO", "failed": "FALLITO", + "partial": "PARZIALE", "running": "IN CORSO"}.get(run.status.value, run.status.value) + + duration = "" + if run.finished_at and run.started_at: + secs = int((run.finished_at - run.started_at).total_seconds()) + duration = f"{secs // 60}m {secs % 60}s" + + size_mb = f"{run.size_bytes / 1_048_576:.1f} MB" if run.size_bytes else "—" + + integrity_line = "" + if run.integrity_verified is not None: + integrity_line = f"Integrità verificata: {'Sì' if run.integrity_verified else 'NO - CHECKSUM NON CORRISPONDENTE'}\n" + + body = f"""Backup-All CRI Catania — Notifica automatica +═══════════════════════════════════════════════ + +Job: {job.name} +Stato: {status_label} +Inizio: {run.started_at.strftime('%d/%m/%Y %H:%M:%S') if run.started_at else '—'} +Durata: {duration} +Dimensione: {size_mb} +{integrity_line} +{f"Errore: {run.error_message}" if run.error_message else ""} + +Percorso backup: {run.backup_path or '—'} +""" + subject = f"[Backup-All] {job.name} — {status_label}" + send_email(db, job.notify_email, subject, body) diff --git a/requirements.txt b/requirements.txt index 98848b3..4ba6e82 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,3 +13,5 @@ aiofiles==23.2.1 cryptography==42.0.8 smbprotocol==1.13.0 python-dotenv==1.0.1 +bcrypt==4.1.3 +itsdangerous==2.1.2 diff --git a/templates/base.html b/templates/base.html index 779e3e1..2160916 100644 --- a/templates/base.html +++ b/templates/base.html @@ -7,93 +7,320 @@ - - - -
- - - - -
- {% block content %}{% endblock %} -
+ - - - {% block scripts %}{% endblock %} + + + + +
+ {% block content %}{% endblock %} +
+ + + + + + +{% block scripts %}{% endblock %} diff --git a/templates/dashboard.html b/templates/dashboard.html index c77fb65..2b1c404 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -3,140 +3,190 @@ {% block content %}
-

Dashboard

- +
+
Dashboard
+
Aggiornamento automatico ogni 30s
+
+
+ Nuovo Job + +
- -
+ +
-
-
- -
Server configurati
-
+
+
+
Sorgenti configurate
-
-
- -
Job attivi
-
+
+
+
Job attivi
-
-
- -
Backup riusciti (7gg)
-
+
+
+
Successi (ultimi 7gg)
-
-
- -
Backup falliti (7gg)
-
+
+
+
Falliti (ultimi 7gg)
- +
-
+
- Job di Backup - Nuovo + Job di Backup + Gestisci tutti
- - + + - + + +
NomeServerTipoProssimaUltimo
NomeSorgenteTipoStatoUltimo run
Caricamento…
+ Caricamento… +
- +
-
-
Ultime esecuzioni
-
-
Caricamento…
+
+
+ Ultime esecuzioni + Storico
-
+ + + {% endblock %} {% block scripts %} - {% endblock %} diff --git a/templates/history.html b/templates/history.html index 8344ba2..531e5f3 100644 --- a/templates/history.html +++ b/templates/history.html @@ -3,48 +3,52 @@ {% block content %}
-

Storico Esecuzioni

-
-
-
- - - - - - -
JobAvviatoDurataDimensionePercorsoStatoAvviato da
Caricamento…
+
+
+
+ + + + + + + + +
JobAvviato ilDurataDimensioneIntegritàStatoAvviato da
Caricamento…
+
{% endblock %} {% block scripts %} {% endblock %} diff --git a/templates/jobs.html b/templates/jobs.html index 904ce41..1b050b1 100644 --- a/templates/jobs.html +++ b/templates/jobs.html @@ -3,58 +3,91 @@ {% block content %}
-

Job di Backup

- Nuovo Job +
Job di Backup
+ Nuovo Job
-
-
- - - - - - -
NomeServerDestinazioneTipoScheduleStatoUltimo runAzioni
Caricamento…
+
+
+
+ + + + + + + + +
NomeSorgenteDestinazioneTipoScheduleRetentionStatoUltimo run
Caricamento…
+
{% endblock %} {% block scripts %} + + + diff --git a/templates/logs.html b/templates/logs.html index 904885c..28bccc7 100644 --- a/templates/logs.html +++ b/templates/logs.html @@ -2,44 +2,72 @@ {% block title %}Log Esecuzione #{{ run_id }}{% endblock %} {% block content %} -
-
Log Esecuzione #{{ run_id }}
-
- - Torna allo storico -
+
+ +
Log Esecuzione #{{ run_id }}
+
-
-
-
Job
-
Avviato
-
Durata
-
Dimensione
-
Percorso
-
Stato
+
+
+
JOB
+
STATO
+
AVVIATO
+
DURATA
+
DIMENSIONE
+
INTEGRITÀ
+
PERCORSO BACKUP
+
+
+ + + -
Caricamento log…
+
+
+ Output +
+ + +
+
+
Caricamento log…
+
{% endblock %} {% block scripts %} {% endblock %} diff --git a/templates/settings.html b/templates/settings.html new file mode 100644 index 0000000..bce48e6 --- /dev/null +++ b/templates/settings.html @@ -0,0 +1,222 @@ +{% extends "base.html" %} +{% block title %}Impostazioni{% endblock %} + +{% block content %} +
+
Impostazioni
+
+ +
+ +
+
+
+ Notifiche Email (SMTP) + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ +
+
+
+
+ + +
+
+
+ Gestione Utenti + +
+
+ + + + + +
UsernameRuoloUltimo accesso
Caricamento…
+
+
+
+ + +
+
+
Informazioni Sistema
+
+
+
VersioneBackup-All v2.0
+
OrganizzazioneCRI — Comitato di Catania
+
DatabaseSQLite (locale)
+
FrameworkFastAPI + APScheduler
+
+
+
+
+
+ + + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/setup_wizard.html b/templates/setup_wizard.html new file mode 100644 index 0000000..53fb0f6 --- /dev/null +++ b/templates/setup_wizard.html @@ -0,0 +1,116 @@ + + + + + + Configurazione iniziale — Backup-All + + + + + +
+
+ +

Configurazione Iniziale

+

Backup-All — CRI Catania

+
+ +
+
+
+
+
+ + +
+
1/3 Accedi con l'account admin
+
+ + +
+
+ + +
+ + +
+ + + + + + +
+ + + + diff --git a/templates/wizard_job.html b/templates/wizard_job.html index 87c261f..356e718 100644 --- a/templates/wizard_job.html +++ b/templates/wizard_job.html @@ -2,127 +2,203 @@ {% block title %}Nuovo Job di Backup{% endblock %} {% block content %} +
+ +
Nuovo Job di Backup
+
+
-
-
-
-
Crea Job di Backup
-
-
-
+
+ + + +
+
+ 1 + Sorgente e Destinazione +
+
- +
- + + +
+
+ +
- + -
-
- - +
+
+
- -
Schedulazione
-
-
- -
-
- -
-
- -
-
- -
+ +
+
+ 2 + Pianificazione +
+
+
+ + + + +
+
+
+
+
+
+
+
+
+ + 0 2 * * * + — Ogni notte alle 02:00 +
+
+
- -
-
-
-
- - -
-
- - -
-
- - -
-
- - + +
+
+ 3 + Retention e Opzioni +
+
+
+
+ +
+ + backup +
+
I backup più vecchi vengono rimossi automaticamente
+
+
+ +
+
+ +
-
- - +
+ +
-
- Espressione cron: - 0 2 * * * - Ogni notte alle 02:00 -
+
+
+ +
+
+ 4 + Notifiche Email +
+
-
- - -
-
- +
+
+
+ +
+
+ + +
+
+ + +
+
+
- -
- + - +
+
+ +
+ Annulla +
-
+
{% endblock %} {% block scripts %} {% endblock %} From ead12c3487f0da7e57f97bb30a74b83f4edc4321 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 13:20:00 +0000 Subject: [PATCH 03/74] feat: pagina Report & Statistiche con grafici e export CSV MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Endpoint /api/stats/summary: KPI globali (successi, falliti, tasso, dimensione totale/media, durata media, integrità) - Endpoint /api/stats/by-job: statistiche per singolo job nel periodo selezionato - Endpoint /api/stats/trend: serie temporale giornaliera successi/fallimenti - Endpoint /api/stats/export-csv: esportazione esecuzioni in CSV scaricabile - Pagina /reports: grafico barre trend giornaliero (Chart.js), donut distribuzione stato, tabella dettagliata per job con tasso di successo colorato, dimensione media, durata media - Filtro periodo: 7/30/90/365 giorni - Voce "Report & Statistiche" aggiunta alla sidebar https://claude.ai/code/session_01AfKVb7RehwV197JyXeeqab --- app/api/stats.py | 161 +++++++++++++++++++++++ app/main.py | 10 +- templates/base.html | 3 + templates/reports.html | 285 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 458 insertions(+), 1 deletion(-) create mode 100644 app/api/stats.py create mode 100644 templates/reports.html diff --git a/app/api/stats.py b/app/api/stats.py new file mode 100644 index 0000000..7a20bbe --- /dev/null +++ b/app/api/stats.py @@ -0,0 +1,161 @@ +"""Statistiche e report aggregati sui backup.""" +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from sqlalchemy import func, case +from datetime import datetime, timezone, timedelta + +from app.database import get_db +from app.models import BackupRun, BackupJob, Server, BackupDestination, RunStatus + +router = APIRouter(prefix="/api/stats", tags=["stats"]) + + +@router.get("/summary") +def summary(days: int = 30, db: Session = Depends(get_db)): + """Statistiche globali per il periodo richiesto.""" + since = datetime.now(timezone.utc) - timedelta(days=days) + + runs = db.query(BackupRun).filter(BackupRun.started_at >= since).all() + + total = len(runs) + success = sum(1 for r in runs if r.status == RunStatus.SUCCESS) + failed = sum(1 for r in runs if r.status == RunStatus.FAILED) + partial = sum(1 for r in runs if r.status == RunStatus.PARTIAL) + running = sum(1 for r in runs if r.status == RunStatus.RUNNING) + + durations = [ + (r.finished_at - r.started_at).total_seconds() + for r in runs + if r.finished_at and r.started_at and r.status == RunStatus.SUCCESS + ] + sizes = [r.size_bytes for r in runs if r.size_bytes and r.status == RunStatus.SUCCESS] + + integrity_ok = sum(1 for r in runs if r.integrity_verified is True) + integrity_fail = sum(1 for r in runs if r.integrity_verified is False) + + return { + "period_days": days, + "total_runs": total, + "success": success, + "failed": failed, + "partial": partial, + "running": running, + "success_rate": round(success / total * 100, 1) if total else 0, + "total_size_bytes": sum(sizes), + "avg_size_bytes": int(sum(sizes) / len(sizes)) if sizes else 0, + "avg_duration_seconds": round(sum(durations) / len(durations)) if durations else 0, + "integrity_ok": integrity_ok, + "integrity_fail": integrity_fail, + "jobs_count": db.query(BackupJob).count(), + "servers_count": db.query(Server).count(), + "destinations_count": db.query(BackupDestination).count(), + } + + +@router.get("/by-job") +def by_job(days: int = 30, db: Session = Depends(get_db)): + """Statistiche per singolo job.""" + since = datetime.now(timezone.utc) - timedelta(days=days) + jobs = db.query(BackupJob).all() + result = [] + for job in jobs: + runs = [r for r in job.runs if r.started_at and r.started_at.replace(tzinfo=timezone.utc) >= since] + if not runs and not job.last_run_at: + runs_all = job.runs + else: + runs_all = runs + + total = len(runs) + success = sum(1 for r in runs if r.status == RunStatus.SUCCESS) + failed = sum(1 for r in runs if r.status == RunStatus.FAILED) + sizes = [r.size_bytes for r in runs if r.size_bytes and r.status == RunStatus.SUCCESS] + durations = [ + (r.finished_at - r.started_at).total_seconds() + for r in runs + if r.finished_at and r.started_at and r.status == RunStatus.SUCCESS + ] + result.append({ + "job_id": job.id, + "job_name": job.name, + "server_name": job.server.name if job.server else "—", + "backup_type": job.backup_type, + "status": job.status, + "total_runs": total, + "success": success, + "failed": failed, + "success_rate": round(success / total * 100, 1) if total else None, + "avg_size_bytes": int(sum(sizes) / len(sizes)) if sizes else 0, + "avg_duration_seconds": round(sum(durations) / len(durations)) if durations else 0, + "last_run_at": job.last_run_at.isoformat() if job.last_run_at else None, + "last_run_status": job.last_run_status, + "retention_copies": job.retention_copies, + "verify_integrity": job.verify_integrity, + "notify_email": job.notify_email, + }) + return result + + +@router.get("/trend") +def trend(days: int = 30, db: Session = Depends(get_db)): + """Trend giornaliero: successi/fallimenti per ciascun giorno.""" + since = datetime.now(timezone.utc) - timedelta(days=days) + runs = db.query(BackupRun).filter( + BackupRun.started_at >= since, + BackupRun.status.in_([RunStatus.SUCCESS, RunStatus.FAILED, RunStatus.PARTIAL]) + ).all() + + days_map: dict[str, dict] = {} + for i in range(days): + day = (datetime.now(timezone.utc) - timedelta(days=days - 1 - i)).strftime("%Y-%m-%d") + days_map[day] = {"date": day, "success": 0, "failed": 0, "partial": 0, "size_bytes": 0} + + for r in runs: + day = r.started_at.strftime("%Y-%m-%d") + if day in days_map: + days_map[day][r.status.value] = days_map[day].get(r.status.value, 0) + 1 + if r.size_bytes: + days_map[day]["size_bytes"] += r.size_bytes + + return list(days_map.values()) + + +@router.get("/export-csv") +def export_csv(days: int = 30, db: Session = Depends(get_db)): + """Esporta le esecuzioni in CSV.""" + from fastapi.responses import StreamingResponse + import io, csv + + since = datetime.now(timezone.utc) - timedelta(days=days) + runs = db.query(BackupRun).filter(BackupRun.started_at >= since).order_by(BackupRun.started_at.desc()).all() + + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(["ID", "Job", "Stato", "Avviato", "Terminato", "Durata (s)", + "Dimensione (MB)", "Integrità", "Percorso", "Avviato da", "Errore"]) + for r in runs: + duration = "" + if r.started_at and r.finished_at: + duration = round((r.finished_at - r.started_at).total_seconds()) + size_mb = round(r.size_bytes / 1_048_576, 2) if r.size_bytes else "" + integrity = {True: "OK", False: "FALLITA", None: ""}.get(r.integrity_verified, "") + writer.writerow([ + r.id, + r.job.name if r.job else f"#{r.job_id}", + r.status.value, + r.started_at.strftime("%d/%m/%Y %H:%M:%S") if r.started_at else "", + r.finished_at.strftime("%d/%m/%Y %H:%M:%S") if r.finished_at else "", + duration, + size_mb, + integrity, + r.backup_path or "", + r.triggered_by or "", + r.error_message or "", + ]) + + output.seek(0) + filename = f"backup-report-{datetime.now().strftime('%Y%m%d')}.csv" + return StreamingResponse( + iter([output.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": f"attachment; filename={filename}"} + ) diff --git a/app/main.py b/app/main.py index 6be870c..e9e0d36 100644 --- a/app/main.py +++ b/app/main.py @@ -8,7 +8,7 @@ from app.database import init_db from app.api import servers, destinations, jobs, runs -from app.api import auth_routes, settings as settings_api +from app.api import auth_routes, settings as settings_api, stats as stats_api _key_file = ".secret_key" if os.path.exists(_key_file): @@ -65,6 +65,7 @@ def _ensure_admin_exists(): app.include_router(runs.router) app.include_router(auth_routes.router) app.include_router(settings_api.router) +app.include_router(stats_api.router) # ── Auth middleware per pagine UI ───────────────────── @@ -149,3 +150,10 @@ async def settings_page(request: Request): if not _check_session(request): return RedirectResponse(url="/login") return templates.TemplateResponse("settings.html", {"request": request}) + + +@app.get("/reports") +async def reports_page(request: Request): + if not _check_session(request): + return RedirectResponse(url="/login") + return templates.TemplateResponse("reports.html", {"request": request}) diff --git a/templates/base.html b/templates/base.html index 2160916..8081afe 100644 --- a/templates/base.html +++ b/templates/base.html @@ -196,6 +196,9 @@ Storico Esecuzioni + + Report & Statistiche + diff --git a/templates/reports.html b/templates/reports.html new file mode 100644 index 0000000..ab46640 --- /dev/null +++ b/templates/reports.html @@ -0,0 +1,285 @@ +{% extends "base.html" %} +{% block title %}Report & Statistiche{% endblock %} + +{% block content %} +
+
Report & Statistiche
+
+ + + +
+
+ + +
+
+
+
+
Successi
+
+
+
+
+
+
Falliti
+
+
+
+
+
+
Tasso successo
+
+
+
+
+
+
Dati totali
+
+
+
+
+
+
Durata media
+
+
+
+
+
+
Integrità OK
+
+
+
+ +
+ +
+
+
+ Trend Giornaliero +
+
+ +
+
+
+ + +
+
+
+ Distribuzione +
+
+
+ +
+
+ +
+
+
+ + +
+
+ Dettaglio per Job + Ultimi 30 giorni +
+
+
+ + + + + + + + + + + + + + + +
JobSorgenteTipoEseguitiSuccessiFallitiTassoDim. mediaDurata mediaUltimo runIntegrità
Caricamento…
+
+
+
+{% endblock %} + +{% block scripts %} + + +{% endblock %} From 717bc9221cbee7d9e6b78d8f76025799be092437 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 13:36:15 +0000 Subject: [PATCH 04/74] fix: aggiungi cartella static mancante https://claude.ai/code/session_01AfKVb7RehwV197JyXeeqab --- static/.gitkeep | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 static/.gitkeep diff --git a/static/.gitkeep b/static/.gitkeep new file mode 100644 index 0000000..e69de29 From 429fd96f933c081886aa673001466e22b4e30f86 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 16:52:49 +0000 Subject: [PATCH 05/74] feat: installer interattivo per Ubuntu Server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Chiede porta personalizzata (1024–65535) con verifica che non sia già in uso - Chiede directory di installazione (default /opt/backup-all) - Installa dipendenze di sistema (python3, git, rsync, sshpass, curl) - Auto-installa Python 3.11 via deadsnakes PPA se la versione è < 3.11 - Crea utente di sistema dedicato senza shell (backupall) - Clone repo, virtualenv, pip install con fallback senza pyvmomi - Crea unit file systemd con hardening (NoNewPrivileges, PrivateTmp, ProtectSystem) - Abilita e avvia il servizio automaticamente - Apre la porta su UFW se attivo (con conferma) - Riepilogo finale con URL, credenziali e comandi utili https://claude.ai/code/session_01AfKVb7RehwV197JyXeeqab --- install.sh | 290 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100755 install.sh diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..6cf6e16 --- /dev/null +++ b/install.sh @@ -0,0 +1,290 @@ +#!/usr/bin/env bash +# ============================================================================== +# Backup-All — Installer per Ubuntu Server +# CRI Catania +# ============================================================================== +set -euo pipefail + +# ── Colori ──────────────────────────────────────────── +RED='\033[0;31m'; BOLD='\033[1m'; DIM='\033[2m' +GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m' +NC='\033[0m' + +ok() { echo -e "${GREEN}${BOLD} ✓${NC} $*"; } +info() { echo -e "${CYAN}${BOLD} →${NC} $*"; } +warn() { echo -e "${YELLOW}${BOLD} ⚠${NC} $*"; } +err() { echo -e "${RED}${BOLD} ✗${NC} $*"; exit 1; } +step() { echo -e "\n${BOLD}${CYAN}[$1]${NC} ${BOLD}$2${NC}"; } + +# ── Banner ──────────────────────────────────────────── +clear +echo -e "${RED}${BOLD}" +echo " ██████╗ █████╗ ██████╗██╗ ██╗██╗ ██╗██████╗ █████╗ ██╗ ██╗ " +echo " ██╔══██╗██╔══██╗██╔════╝██║ ██╔╝██║ ██║██╔══██╗ ██╔══██╗██║ ██║ " +echo " ██████╔╝███████║██║ █████╔╝ ██║ ██║██████╔╝ ███████║██║ ██║ " +echo " ██╔══██╗██╔══██║██║ ██╔═██╗ ██║ ██║██╔═══╝ ██╔══██║██║ ██║ " +echo " ██████╔╝██║ ██║╚██████╗██║ ██╗╚██████╔╝██║ ██║ ██║███████╗███████╗" +echo " ╚═════╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝" +echo -e "${NC}" +echo -e " ${DIM}Soluzione di backup unificata — CRI Catania${NC}" +echo -e " ${DIM}────────────────────────────────────────────${NC}\n" + +# ── Root check ──────────────────────────────────────── +[[ $EUID -ne 0 ]] && err "Esegui lo script come root: sudo bash install.sh" + +# ── Variabili di default ────────────────────────────── +INSTALL_DIR="/opt/backup-all" +SERVICE_USER="backupall" +SERVICE_NAME="backup-all" +REPO_URL="https://github.com/ico88/Backup-all.git" +REPO_BRANCH="claude/loving-keller-d6978c" +PYTHON_MIN="3.11" + +# ══════════════════════════════════════════════════════ +# FASE 1 — Raccolta parametri interattiva +# ══════════════════════════════════════════════════════ +step "1/6" "Configurazione" + +# Porta +while true; do + echo -e " ${BOLD}Porta su cui avviare Backup-All${NC}" + echo -e " ${DIM}(scegli una porta libera, es. 8080, 8443, 9000, 9500)${NC}" + read -rp " Porta: " APP_PORT + if [[ "$APP_PORT" =~ ^[0-9]+$ ]] && (( APP_PORT >= 1024 && APP_PORT <= 65535 )); then + # Verifica che la porta non sia già in uso + if ss -tlnp 2>/dev/null | grep -q ":${APP_PORT} " || \ + lsof -iTCP:${APP_PORT} -sTCP:LISTEN -t 2>/dev/null | grep -q .; then + warn "La porta ${APP_PORT} è già in uso. Scegline un'altra." + else + ok "Porta selezionata: ${APP_PORT}" + break + fi + else + warn "Inserisci un numero di porta valido (1024–65535)." + fi +done + +# Directory di installazione +echo "" +echo -e " ${BOLD}Directory di installazione${NC} ${DIM}[default: ${INSTALL_DIR}]${NC}" +read -rp " Percorso (invio per default): " CUSTOM_DIR +[[ -n "$CUSTOM_DIR" ]] && INSTALL_DIR="$CUSTOM_DIR" +ok "Installazione in: ${INSTALL_DIR}" + +# Riepilogo +echo "" +echo -e " ${BOLD}╔══════════════════════════════════════╗${NC}" +echo -e " ${BOLD}║ Riepilogo configurazione ║${NC}" +echo -e " ${BOLD}╠══════════════════════════════════════╣${NC}" +echo -e " ${BOLD}║${NC} Porta: ${GREEN}${BOLD}${APP_PORT}${NC}" +echo -e " ${BOLD}║${NC} Cartella: ${INSTALL_DIR}" +echo -e " ${BOLD}║${NC} Utente svc: ${SERVICE_USER}" +echo -e " ${BOLD}║${NC} Systemd: ${SERVICE_NAME}.service" +echo -e " ${BOLD}╚══════════════════════════════════════╝${NC}" +echo "" +read -rp " Procedere con l'installazione? [S/n]: " CONFIRM +[[ "${CONFIRM,,}" == "n" ]] && { echo " Installazione annullata."; exit 0; } + +# ══════════════════════════════════════════════════════ +# FASE 2 — Dipendenze di sistema +# ══════════════════════════════════════════════════════ +step "2/6" "Installazione dipendenze di sistema" + +info "Aggiornamento lista pacchetti..." +apt-get update -qq + +PKGS=(python3 python3-pip python3-venv git rsync sshpass curl) +for pkg in "${PKGS[@]}"; do + if dpkg -s "$pkg" &>/dev/null; then + ok "$pkg già installato" + else + info "Installazione $pkg..." + apt-get install -y -qq "$pkg" + ok "$pkg installato" + fi +done + +# Verifica versione Python +PY_VER=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')") +PY_MAJOR=$(echo "$PY_VER" | cut -d. -f1) +PY_MINOR=$(echo "$PY_VER" | cut -d. -f2) +if (( PY_MAJOR < 3 || (PY_MAJOR == 3 && PY_MINOR < 11) )); then + warn "Python ${PY_VER} rilevato. Backup-All richiede Python 3.11+." + info "Installazione python3.11 da deadsnakes PPA..." + apt-get install -y -qq software-properties-common + add-apt-repository -y ppa:deadsnakes/ppa + apt-get update -qq + apt-get install -y -qq python3.11 python3.11-venv python3.11-dev + PYTHON_BIN="python3.11" + ok "Python 3.11 installato" +else + PYTHON_BIN="python3" + ok "Python ${PY_VER} — versione compatibile" +fi + +# ══════════════════════════════════════════════════════ +# FASE 3 — Utente di sistema e cartella +# ══════════════════════════════════════════════════════ +step "3/6" "Creazione utente e directory" + +if id "$SERVICE_USER" &>/dev/null; then + ok "Utente '${SERVICE_USER}' già esistente" +else + useradd --system --shell /usr/sbin/nologin --home-dir "${INSTALL_DIR}" \ + --comment "Backup-All service user" "$SERVICE_USER" + ok "Utente di sistema '${SERVICE_USER}' creato" +fi + +# Backup installazione precedente se esiste +if [[ -d "$INSTALL_DIR" ]]; then + BACKUP_DIR="${INSTALL_DIR}.bak.$(date +%Y%m%d_%H%M%S)" + warn "Trovata installazione precedente. Backup in: ${BACKUP_DIR}" + mv "$INSTALL_DIR" "$BACKUP_DIR" +fi + +mkdir -p "$INSTALL_DIR" +ok "Directory ${INSTALL_DIR} creata" + +# ══════════════════════════════════════════════════════ +# FASE 4 — Clone repo e venv +# ══════════════════════════════════════════════════════ +step "4/6" "Download applicazione" + +info "Clone repository..." +git clone --branch "$REPO_BRANCH" --depth 1 "$REPO_URL" "$INSTALL_DIR" 2>&1 | \ + sed 's/^/ /' +ok "Repository clonato" + +info "Creazione virtual environment Python..." +"$PYTHON_BIN" -m venv "${INSTALL_DIR}/.venv" +ok "Virtualenv creato in ${INSTALL_DIR}/.venv" + +info "Installazione dipendenze Python..." +"${INSTALL_DIR}/.venv/bin/pip" install --upgrade pip -q +# Installa senza pyvmomi se la build fallisce (richiede build tools) +"${INSTALL_DIR}/.venv/bin/pip" install -r "${INSTALL_DIR}/requirements.txt" -q 2>&1 || { + warn "Alcune dipendenze opzionali non sono state installate (es. pyvmomi)." + warn "Installazione core senza pyvmomi..." + "${INSTALL_DIR}/.venv/bin/pip" install \ + fastapi uvicorn sqlalchemy alembic paramiko pywinrm requests \ + apscheduler jinja2 python-multipart aiofiles cryptography \ + smbprotocol python-dotenv bcrypt itsdangerous -q +} +ok "Dipendenze Python installate" + +# Crea cartella static se non esiste +mkdir -p "${INSTALL_DIR}/static" + +# Permessi +chown -R "${SERVICE_USER}:${SERVICE_USER}" "$INSTALL_DIR" +chmod 750 "$INSTALL_DIR" +ok "Permessi impostati" + +# ══════════════════════════════════════════════════════ +# FASE 5 — File di configurazione e systemd +# ══════════════════════════════════════════════════════ +step "5/6" "Configurazione servizio systemd" + +# File .env +cat > "${INSTALL_DIR}/.env" << EOF +PORT=${APP_PORT} +DEV=false +EOF +chown "${SERVICE_USER}:${SERVICE_USER}" "${INSTALL_DIR}/.env" +ok "File .env creato" + +# Unit file systemd +cat > "/etc/systemd/system/${SERVICE_NAME}.service" << EOF +[Unit] +Description=Backup-All — CRI Catania +Documentation=https://github.com/ico88/Backup-all +After=network.target +Wants=network-online.target + +[Service] +Type=simple +User=${SERVICE_USER} +Group=${SERVICE_USER} +WorkingDirectory=${INSTALL_DIR} +Environment="PORT=${APP_PORT}" +EnvironmentFile=-${INSTALL_DIR}/.env +ExecStart=${INSTALL_DIR}/.venv/bin/python ${INSTALL_DIR}/run.py +Restart=on-failure +RestartSec=10 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=${SERVICE_NAME} + +# Limiti di sicurezza +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ReadWritePaths=${INSTALL_DIR} +ProtectHome=true + +[Install] +WantedBy=multi-user.target +EOF + +ok "Unit file /etc/systemd/system/${SERVICE_NAME}.service creato" + +# Reload e avvio +systemctl daemon-reload +systemctl enable "${SERVICE_NAME}" +systemctl start "${SERVICE_NAME}" + +# Attendi che si avvii +sleep 3 +if systemctl is-active --quiet "${SERVICE_NAME}"; then + ok "Servizio avviato con successo" +else + warn "Il servizio non sembra attivo. Controlla con: journalctl -u ${SERVICE_NAME} -n 50" +fi + +# ══════════════════════════════════════════════════════ +# FASE 6 — Firewall (opzionale) +# ══════════════════════════════════════════════════════ +step "6/6" "Firewall" + +if command -v ufw &>/dev/null && ufw status | grep -q "Status: active"; then + read -rp " UFW attivo. Aprire la porta ${APP_PORT}/tcp? [S/n]: " UFW_OPEN + if [[ "${UFW_OPEN,,}" != "n" ]]; then + ufw allow "${APP_PORT}/tcp" comment "Backup-All" + ok "Porta ${APP_PORT}/tcp aperta nel firewall" + else + warn "Ricordati di aprire la porta ${APP_PORT} manualmente se necessario" + fi +else + info "UFW non attivo o non installato — nessuna regola firewall aggiunta" +fi + +# ══════════════════════════════════════════════════════ +# Riepilogo finale +# ══════════════════════════════════════════════════════ +SERVER_IP=$(hostname -I | awk '{print $1}') + +echo "" +echo -e "${GREEN}${BOLD}" +echo " ╔══════════════════════════════════════════════════╗" +echo " ║ Installazione completata! ✓ ║" +echo " ╚══════════════════════════════════════════════════╝" +echo -e "${NC}" +echo -e " ${BOLD}Accedi da browser:${NC}" +echo -e " ${CYAN}${BOLD} → http://${SERVER_IP}:${APP_PORT}${NC}" +echo "" +echo -e " ${BOLD}Credenziali iniziali:${NC}" +echo -e " Username: ${BOLD}admin${NC}" +echo -e " Password: ${BOLD}changeme${NC} ${RED}← cambiala subito!${NC}" +echo "" +echo -e " ${BOLD}Comandi utili:${NC}" +echo -e " ${DIM} Stato servizio:${NC} systemctl status ${SERVICE_NAME}" +echo -e " ${DIM} Log in tempo reale:${NC} journalctl -u ${SERVICE_NAME} -f" +echo -e " ${DIM} Riavvio:${NC} systemctl restart ${SERVICE_NAME}" +echo -e " ${DIM} Arresto:${NC} systemctl stop ${SERVICE_NAME}" +echo -e " ${DIM} File log DB:${NC} ${INSTALL_DIR}/backup.db" +echo "" +echo -e " ${DIM}────────────────────────────────────────────────────${NC}" +echo -e " ${DIM}Installato in: ${INSTALL_DIR}${NC}" +echo -e " ${DIM}Utente servizio: ${SERVICE_USER}${NC}" +echo -e " ${DIM}Systemd unit: /etc/systemd/system/${SERVICE_NAME}.service${NC}" +echo "" From 970bfeb4128a3e8a38902985befeb44788796902 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 04:52:10 +0000 Subject: [PATCH 06/74] Refactor: remove brand references, add edit/test UI, fix Fernet key - Remove all placeholder app names (gamma, abulafia, TeamSystem) from templates and backend comments; use generic terms - wizard_server + wizard_destination: list with colored badges at top, click row to edit via modal (PUT endpoint), test button in modal - PUT /api/servers/{id} and PUT /api/destinations/{id} update endpoints - POST /api/servers/{id}/test: SSH (Linux) / TCP port check (Windows) - POST /api/destinations/{id}/test: improved SSH test with readable errors - crypto.py: validate key on load, regenerate if corrupted, decrypt() returns "" instead of crashing on bad key - install.sh: generate valid Fernet key into .env at install time - update.sh: rsync code-only update script (skips .venv/.db/.env) - base.html badges: solid opaque backgrounds for high contrast https://claude.ai/code/session_01AfKVb7RehwV197JyXeeqab --- app/api/destinations.py | 79 ++++- app/api/servers.py | 79 +++++ app/backup/linux.py | 2 +- app/backup/qnap.py | 4 +- app/backup/windows.py | 2 +- app/crypto.py | 40 ++- install.sh | 20 +- templates/base.html | 20 +- templates/wizard_destination.html | 313 ++++++++++-------- templates/wizard_job.html | 2 +- templates/wizard_server.html | 518 ++++++++++++++---------------- update.sh | 86 +++++ 12 files changed, 706 insertions(+), 459 deletions(-) create mode 100755 update.sh diff --git a/app/api/destinations.py b/app/api/destinations.py index efd3b88..2e5be18 100644 --- a/app/api/destinations.py +++ b/app/api/destinations.py @@ -13,7 +13,7 @@ class DestinationCreate(BaseModel): name: str description: Optional[str] = None - dest_type: str # "rsync", "qnap_api" + dest_type: str host: str port: Optional[int] = None username: Optional[str] = None @@ -46,31 +46,78 @@ def create_destination(data: DestinationCreate, db: Session = Depends(get_db)): return {"id": d.id, "name": d.name} +@router.put("/{dest_id}") +def update_destination(dest_id: int, data: DestinationCreate, db: Session = Depends(get_db)): + d = db.get(BackupDestination, dest_id) + if not d: + raise HTTPException(404, "Destinazione non trovata") + d.name = data.name + d.description = data.description + d.dest_type = data.dest_type + d.host = data.host + d.port = data.port + d.username = data.username + if data.password: + d.password_enc = encrypt(data.password) + d.base_path = data.base_path + d.rsync_module = data.rsync_module + d.max_retention_days = data.max_retention_days + db.commit() + db.refresh(d) + return _serialize(d) + + @router.post("/{dest_id}/test") def test_destination(dest_id: int, db: Session = Depends(get_db)): - """Verifica la connessione al QNAP.""" + import subprocess, os, shutil, logging + from app.crypto import decrypt + log = logging.getLogger("backup-all.test") + d = db.get(BackupDestination, dest_id) if not d: raise HTTPException(404, "Destinazione non trovata") - import subprocess, os - from app.crypto import decrypt + password = decrypt(d.password_enc) if d.password_enc else "" port = d.port or 22 + + has_sshpass = shutil.which("sshpass") is not None + if password and not has_sshpass: + raise HTTPException(500, "sshpass non installato sul server. Esegui: sudo apt install sshpass") + env = os.environ.copy() - if password: + if password and has_sshpass: env["SSHPASS"] = password - ssh_prefix = ["sshpass", "-e", "ssh"] + cmd = ["sshpass", "-e", "ssh"] else: - ssh_prefix = ["ssh"] - result = subprocess.run( - ssh_prefix + ["-p", str(port), "-o", "ConnectTimeout=5", - "-o", "StrictHostKeyChecking=no", - f"{d.username}@{d.host}", "echo OK"], - capture_output=True, text=True, env=env, timeout=10 - ) - if result.returncode == 0: - return {"ok": True, "message": "Connessione al QNAP riuscita"} - raise HTTPException(500, f"Connessione fallita: {result.stderr}") + cmd = ["ssh"] + + cmd += ["-p", str(port), "-o", "ConnectTimeout=8", + "-o", "StrictHostKeyChecking=no", + f"{d.username}@{d.host}", "echo OK"] + + log.info("Test QNAP: %s@%s:%s", d.username, d.host, port) + try: + result = subprocess.run(cmd, capture_output=True, text=True, env=env, timeout=15) + except subprocess.TimeoutExpired: + raise HTTPException(500, f"Timeout: {d.host}:{port} non risponde entro 15 secondi") + except FileNotFoundError as e: + raise HTTPException(500, f"Comando non trovato: {e}") + + log.info("rc=%d stdout=%r stderr=%r", result.returncode, result.stdout[:200], result.stderr[:200]) + + if result.returncode == 0 and "OK" in result.stdout: + return {"ok": True, "message": f"Connessione SSH a {d.host}:{port} riuscita ✓"} + + stderr = result.stderr.strip() + if "Permission denied" in stderr or "Authentication failed" in stderr: + raise HTTPException(500, f"Credenziali errate per {d.username}@{d.host}") + elif "Connection refused" in stderr: + raise HTTPException(500, f"Connessione rifiutata su {d.host}:{port} — SSH abilitato sul QNAP?") + elif "No route to host" in stderr or "Network unreachable" in stderr: + raise HTTPException(500, f"Host {d.host} non raggiungibile — controlla IP e rete") + elif "Connection timed out" in stderr: + raise HTTPException(500, f"Timeout connessione a {d.host}:{port}") + raise HTTPException(500, f"SSH error (rc={result.returncode}): {stderr[:300] or 'nessun output'}") @router.delete("/{dest_id}") diff --git a/app/api/servers.py b/app/api/servers.py index 702ecf8..1f3f61a 100644 --- a/app/api/servers.py +++ b/app/api/servers.py @@ -117,6 +117,83 @@ def create_server(data: ServerCreate, db: Session = Depends(get_db)): return {"id": s.id, "name": s.name} +@router.put("/{server_id}") +def update_server(server_id: int, data: ServerCreate, db: Session = Depends(get_db)): + s = db.get(Server, server_id) + if not s: + raise HTTPException(404, "Server non trovato") + s.name = data.name + s.description = data.description + s.server_type = data.server_type + s.ip_address = data.ip_address + s.vm_name = data.vm_name + s.vmware_host_id = data.vmware_host_id + s.ssh_port = data.ssh_port + s.winrm_port = data.winrm_port + s.username = data.username + if data.password: + s.password_enc = encrypt(data.password) + s.ssh_key_path = data.ssh_key_path + s.app_name = data.app_name + s.app_data_paths = json.dumps(data.app_data_paths or []) + s.app_db_type = data.app_db_type + s.app_db_name = data.app_db_name + s.app_db_user = data.app_db_user + if data.app_db_password: + s.app_db_password_enc = encrypt(data.app_db_password) + db.commit() + db.refresh(s) + return _serialize(s) + + +@router.post("/{server_id}/test") +def test_server(server_id: int, db: Session = Depends(get_db)): + import subprocess, os, shutil, socket, logging + log = logging.getLogger("backup-all.test") + s = db.get(Server, server_id) + if not s: + raise HTTPException(404, "Server non trovato") + password = decrypt(s.password_enc) if s.password_enc else "" + if s.server_type == "linux": + port = s.ssh_port or 22 + has_sshpass = shutil.which("sshpass") is not None + if password and not has_sshpass: + raise HTTPException(500, "sshpass non installato: sudo apt install sshpass") + env = os.environ.copy() + if password and has_sshpass: + env["SSHPASS"] = password + cmd = ["sshpass", "-e", "ssh"] + else: + cmd = ["ssh"] + cmd += ["-p", str(port), "-o", "ConnectTimeout=8", + "-o", "StrictHostKeyChecking=no", + f"{s.username}@{s.ip_address}", "echo OK"] + log.info("Test SSH: %s@%s:%s", s.username, s.ip_address, port) + try: + result = subprocess.run(cmd, capture_output=True, text=True, env=env, timeout=15) + except subprocess.TimeoutExpired: + raise HTTPException(500, f"Timeout: {s.ip_address}:{port} non risponde") + log.info("rc=%d stdout=%r stderr=%r", result.returncode, result.stdout[:200], result.stderr[:200]) + if result.returncode == 0 and "OK" in result.stdout: + return {"ok": True, "message": f"Connessione SSH a {s.ip_address}:{port} riuscita ✓"} + stderr = result.stderr.strip() + if "Permission denied" in stderr or "Authentication failed" in stderr: + raise HTTPException(500, f"Credenziali errate per {s.username}@{s.ip_address}") + elif "Connection refused" in stderr: + raise HTTPException(500, f"Connessione rifiutata su {s.ip_address}:{port}") + elif "No route to host" in stderr or "Network unreachable" in stderr: + raise HTTPException(500, f"Host {s.ip_address} non raggiungibile") + raise HTTPException(500, f"SSH error (rc={result.returncode}): {stderr[:200]}") + else: + port = s.winrm_port or 5985 + try: + with socket.create_connection((s.ip_address, port), timeout=5): + pass + return {"ok": True, "message": f"Porta WinRM {port} raggiungibile su {s.ip_address} ✓"} + except Exception as e: + raise HTTPException(500, f"WinRM non raggiungibile su {s.ip_address}:{port} — {e}") + + @router.delete("/{server_id}") def delete_server(server_id: int, db: Session = Depends(get_db)): s = db.get(Server, server_id) @@ -131,6 +208,8 @@ def _serialize(s: Server) -> dict: return { "id": s.id, "name": s.name, "description": s.description, "server_type": s.server_type, "ip_address": s.ip_address, + "ssh_port": s.ssh_port, "winrm_port": s.winrm_port, + "username": s.username, "vm_name": s.vm_name, "vmware_host_id": s.vmware_host_id, "app_name": s.app_name, "app_data_paths": json.loads(s.app_data_paths or "[]"), diff --git a/app/backup/linux.py b/app/backup/linux.py index 6d2d935..a391876 100644 --- a/app/backup/linux.py +++ b/app/backup/linux.py @@ -1,4 +1,4 @@ -"""Backup dati applicativi da server Linux (Abulafia) via SSH/rsync.""" +"""Backup dati applicativi da server Linux via SSH/rsync.""" import json import subprocess import paramiko diff --git a/app/backup/qnap.py b/app/backup/qnap.py index 59481e4..695a0c3 100644 --- a/app/backup/qnap.py +++ b/app/backup/qnap.py @@ -13,7 +13,7 @@ def rsync_to_qnap(dest_cfg, local_path: str, remote_subpath: str, log_fn=None) - """ Invia local_path al QNAP via rsync over SSH. dest_cfg: BackupDestination model instance. - remote_subpath: es. "windows_gamma/2024-01-15" + remote_subpath: es. "server-01/2024-01-15" Ritorna byte trasferiti. """ password = decrypt(dest_cfg.password_enc) if dest_cfg.password_enc else "" @@ -176,7 +176,7 @@ def compute_remote_checksum(dest_cfg, remote_path: str) -> str | None: def apply_retention(dest_cfg, remote_base: str, retention_days: int, log_fn=None): """ Rimuove backup più vecchi di retention_days giorni via SSH sul QNAP. - remote_base: percorso base sul QNAP (es. /backup/windows_gamma) + remote_base: percorso base sul QNAP (es. /backup/server-01) """ if not retention_days: return diff --git a/app/backup/windows.py b/app/backup/windows.py index 254979f..2b4b1c1 100644 --- a/app/backup/windows.py +++ b/app/backup/windows.py @@ -1,4 +1,4 @@ -"""Backup dati applicativi da server Windows (Gamma/TeamSystem) via WinRM.""" +"""Backup dati applicativi da server Windows via WinRM.""" import json import os import winrm diff --git a/app/crypto.py b/app/crypto.py index 81f79a7..41b96df 100644 --- a/app/crypto.py +++ b/app/crypto.py @@ -1,21 +1,42 @@ """Cifratura semplice per credenziali nel DB usando Fernet.""" import os -import base64 from cryptography.fernet import Fernet _KEY_ENV = "BACKUP_SECRET_KEY" +_KEY_FILE = ".secret_key" + + +def _is_valid_fernet_key(key: str) -> bool: + try: + Fernet(key.encode() if isinstance(key, str) else key) + return True + except Exception: + return False def _get_fernet() -> Fernet: key = os.getenv(_KEY_ENV) + + if key and not _is_valid_fernet_key(key): + key = None + os.environ.pop(_KEY_ENV, None) + if not key: - # Genera e salva una chiave al primo avvio (solo sviluppo) - key = Fernet.generate_key().decode() - os.environ[_KEY_ENV] = key - key_file = ".secret_key" - if not os.path.exists(key_file): - with open(key_file, "w") as f: + if os.path.exists(_KEY_FILE): + with open(_KEY_FILE, "r") as f: + candidate = f.read().strip() + if _is_valid_fernet_key(candidate): + key = candidate + else: + key = Fernet.generate_key().decode() + with open(_KEY_FILE, "w") as f: + f.write(key) + else: + key = Fernet.generate_key().decode() + with open(_KEY_FILE, "w") as f: f.write(key) + os.environ[_KEY_ENV] = key + return Fernet(key.encode() if isinstance(key, str) else key) @@ -28,4 +49,7 @@ def encrypt(plaintext: str) -> str: def decrypt(ciphertext: str) -> str: if not ciphertext: return "" - return _get_fernet().decrypt(ciphertext.encode()).decode() + try: + return _get_fernet().decrypt(ciphertext.encode()).decode() + except Exception: + return "" diff --git a/install.sh b/install.sh index 6cf6e16..5428f0a 100755 --- a/install.sh +++ b/install.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # ============================================================================== # Backup-All — Installer per Ubuntu Server -# CRI Catania + # ============================================================================== set -euo pipefail @@ -26,7 +26,7 @@ echo " ██╔══██╗██╔══██║██║ ██╔ echo " ██████╔╝██║ ██║╚██████╗██║ ██╗╚██████╔╝██║ ██║ ██║███████╗███████╗" echo " ╚═════╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝" echo -e "${NC}" -echo -e " ${DIM}Soluzione di backup unificata — CRI Catania${NC}" +echo -e " ${DIM}Soluzione di backup unificata${NC}" echo -e " ${DIM}────────────────────────────────────────────${NC}\n" # ── Root check ──────────────────────────────────────── @@ -185,18 +185,26 @@ ok "Permessi impostati" # ══════════════════════════════════════════════════════ step "5/6" "Configurazione servizio systemd" -# File .env -cat > "${INSTALL_DIR}/.env" << EOF +# File .env — genera chiave Fernet se non già presente +if [[ -f "${INSTALL_DIR}/.env" ]] && grep -q "^BACKUP_SECRET_KEY=" "${INSTALL_DIR}/.env" 2>/dev/null; then + ok "Chiave di cifratura esistente preservata" +else + FERNET_KEY=$("${INSTALL_DIR}/.venv/bin/python" -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())") + cat > "${INSTALL_DIR}/.env" << EOF PORT=${APP_PORT} DEV=false +BACKUP_SECRET_KEY=${FERNET_KEY} EOF + ok "Chiave di cifratura generata" +fi chown "${SERVICE_USER}:${SERVICE_USER}" "${INSTALL_DIR}/.env" -ok "File .env creato" +chmod 640 "${INSTALL_DIR}/.env" +ok "File .env configurato (porta: ${APP_PORT})" # Unit file systemd cat > "/etc/systemd/system/${SERVICE_NAME}.service" << EOF [Unit] -Description=Backup-All — CRI Catania +Description=Backup-All Documentation=https://github.com/ico88/Backup-all After=network.target Wants=network-online.target diff --git a/templates/base.html b/templates/base.html index 8081afe..bb48e87 100644 --- a/templates/base.html +++ b/templates/base.html @@ -84,17 +84,17 @@ .table td { border-color: var(--card-border); padding: .7rem 1rem; vertical-align: middle; } .table-hover tbody tr:hover td { background: rgba(255,255,255,.03); } - /* Status badges */ - .badge-status { display: inline-flex; align-items: center; gap: .35rem; padding: .22rem .6rem; border-radius: 20px; font-size: .72rem; font-weight: 600; } + /* Status badges — sfondo solido, testo chiaro ad alto contrasto */ + .badge-status { display: inline-flex; align-items: center; gap: .35rem; padding: .22rem .65rem; border-radius: 20px; font-size: .72rem; font-weight: 600; } .badge-status::before { content:''; width:6px; height:6px; border-radius:50%; background:currentColor; } - .bs-success { background:rgba(34,197,94,.12); color:var(--success); } - .bs-failed { background:rgba(239,68,68,.12); color:var(--danger); } - .bs-running { background:rgba(59,130,246,.12); color:var(--info); animation: pulse-run 1.5s infinite; } - .bs-partial { background:rgba(245,158,11,.12); color:var(--warning); } - .bs-active { background:rgba(34,197,94,.1); color:var(--success); } - .bs-paused { background:rgba(245,158,11,.1); color:var(--warning); } - .bs-disabled{ background:rgba(107,114,128,.1); color:var(--text-muted); } - @keyframes pulse-run { 0%,100%{opacity:1} 50%{opacity:.5} } + .bs-success { background:#166534; color:#bbf7d0; } + .bs-failed { background:#7f1d1d; color:#fecaca; } + .bs-running { background:#1e3a5f; color:#bae6fd; animation: pulse-run 1.5s infinite; } + .bs-partial { background:#78350f; color:#fde68a; } + .bs-active { background:#166534; color:#bbf7d0; } + .bs-paused { background:#78350f; color:#fde68a; } + .bs-disabled{ background:#1f2937; color:#9ca3af; } + @keyframes pulse-run { 0%,100%{opacity:1} 50%{opacity:.6} } /* Buttons */ .btn-primary { background:var(--cri-red); border-color:var(--cri-red); color:#fff; } diff --git a/templates/wizard_destination.html b/templates/wizard_destination.html index 9e20a10..0803995 100644 --- a/templates/wizard_destination.html +++ b/templates/wizard_destination.html @@ -1,102 +1,112 @@ {% extends "base.html" %} -{% block title %}Aggiungi Destinazione QNAP{% endblock %} +{% block title %}Destinazioni QNAP{% endblock %} {% block content %} -
-
-
-
-
Configura destinazione QNAP
-
-
-
-
- - -
-
- - -
-
- -
-
-
-
- -
rsync over SSH
- Consigliato — efficiente e sicuro -
-
-
-
-
-
- -
API QTS
- Interfaccia nativa QNAP -
-
-
-
-
+
+
Destinazioni QNAP
+ +
-
-
-
- - -
-
- - -
-
-
- - -
-
+
+
Destinazioni configurate
+
+ + + + + +
NomeHostTipoPercorso baseRetention
Caricamento…
+
+
-