app.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607
  1. import json
  2. import logging
  3. import os
  4. import subprocess
  5. from datetime import datetime
  6. from flask import (
  7. Flask,
  8. flash,
  9. jsonify,
  10. redirect,
  11. render_template,
  12. request,
  13. url_for,
  14. )
  15. from werkzeug.middleware.proxy_fix import ProxyFix
  16. # --- Configuration -----------------------------------------------------------
  17. _config_path = os.environ.get(
  18. "BACKUPMANAGER_CONFIG",
  19. os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.py"),
  20. )
  21. app = Flask(__name__)
  22. app.config.from_pyfile(_config_path)
  23. app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + app.config["DB_PATH"]
  24. app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
  25. # Proxy headers Nginx → Flask (sous-chemin + HTTPS)
  26. app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
  27. # Filtre Jinja2 pour désérialiser du JSON dans les templates
  28. app.jinja_env.filters["fromjson"] = json.loads
  29. # Logging
  30. os.makedirs(os.path.dirname(app.config["LOG_PATH"]), exist_ok=True)
  31. logging.basicConfig(
  32. filename=app.config["LOG_PATH"],
  33. level=logging.INFO,
  34. format="%(asctime)s %(levelname)s %(message)s",
  35. )
  36. # --- Extensions --------------------------------------------------------------
  37. from db import db, Job, Run, Destination, Setting
  38. db.init_app(app)
  39. from scheduler import init_scheduler, schedule_job, remove_job
  40. # --- Démarrage ---------------------------------------------------------------
  41. with app.app_context():
  42. db.create_all()
  43. init_scheduler(app)
  44. for _job in Job.query.filter_by(enabled=True).all():
  45. schedule_job(_job)
  46. # --- Auth API ----------------------------------------------------------------
  47. @app.before_request
  48. def _check_api_auth():
  49. if not request.path.startswith("/api/"):
  50. return
  51. if request.path == "/api/v1/health":
  52. return
  53. token = request.headers.get("X-BackupManager-Key", "")
  54. if token != app.config["API_TOKEN"]:
  55. return jsonify({"error": "Unauthorized"}), 401
  56. # --- Context processors ------------------------------------------------------
  57. @app.context_processor
  58. def _inject_globals():
  59. return {
  60. "instance_name": app.config.get("INSTANCE_NAME", ""),
  61. "now": datetime.utcnow(),
  62. }
  63. # --- Helpers -----------------------------------------------------------------
  64. def _read_archive_info(archive_name):
  65. backup_dir = app.config["YUNOHOST_BACKUP_DIR"]
  66. archive_path = os.path.join(backup_dir, archive_name + ".tar")
  67. from jobs.utils import sudo_read_backup_info
  68. info = sudo_read_backup_info(archive_path)
  69. if not info.get("type"):
  70. # Archives YunoHost natives : déterminer le type depuis la table Run
  71. run = Run.query.filter_by(archive_name=archive_name).first()
  72. if run:
  73. job = db.session.get(Job, run.job_id)
  74. if job:
  75. info["type"] = job.type
  76. info["_from_run"] = True
  77. return info
  78. def _get_ynh_apps():
  79. try:
  80. result = subprocess.run(
  81. ["sudo", "yunohost", "app", "list", "--output-as", "json"],
  82. capture_output=True,
  83. text=True,
  84. timeout=15,
  85. )
  86. if result.returncode == 0:
  87. return json.loads(result.stdout).get("apps", [])
  88. except Exception:
  89. pass
  90. return []
  91. # --- Routes dashboard --------------------------------------------------------
  92. @app.route("/")
  93. def index():
  94. jobs = Job.query.order_by(Job.name).all()
  95. last_runs = {
  96. j.id: Run.query.filter_by(job_id=j.id).order_by(Run.started_at.desc()).first()
  97. for j in jobs
  98. }
  99. return render_template("dashboard_local.html", jobs=jobs, last_runs=last_runs)
  100. @app.route("/jobs/new", methods=["GET", "POST"])
  101. def job_new():
  102. if request.method == "POST":
  103. return _save_job(None)
  104. return render_template("job_form.html", job=None, ynh_apps=_get_ynh_apps(),
  105. destinations=Destination.query.filter_by(enabled=True).all())
  106. @app.route("/jobs/<int:job_id>/edit", methods=["GET", "POST"])
  107. def job_edit(job_id):
  108. job = db.get_or_404(Job, job_id)
  109. if request.method == "POST":
  110. return _save_job(job)
  111. return render_template("job_form.html", job=job, ynh_apps=_get_ynh_apps(),
  112. destinations=Destination.query.filter_by(enabled=True).all())
  113. @app.route("/jobs/<int:job_id>/delete", methods=["POST"])
  114. def job_delete(job_id):
  115. job = db.get_or_404(Job, job_id)
  116. remove_job(job.id)
  117. db.session.delete(job)
  118. db.session.commit()
  119. flash(f"Job « {job.name} » supprimé.", "success")
  120. return redirect(url_for("index"))
  121. @app.route("/jobs/<int:job_id>/run", methods=["POST"])
  122. def job_run_now(job_id):
  123. job = db.get_or_404(Job, job_id)
  124. from scheduler import _execute_job
  125. import threading
  126. t = threading.Thread(target=_execute_job, args=(job.id,), daemon=True)
  127. t.start()
  128. flash(f"Job « {job.name} » lancé manuellement.", "success")
  129. return redirect(url_for("index"))
  130. @app.route("/jobs/<int:job_id>/history")
  131. def job_history(job_id):
  132. job = db.get_or_404(Job, job_id)
  133. runs = Run.query.filter_by(job_id=job_id).order_by(Run.started_at.desc()).limit(100).all()
  134. return render_template("job_history.html", job=job, runs=runs)
  135. @app.route("/archives/<path:archive_name>/restore", methods=["GET", "POST"])
  136. def archive_restore(archive_name):
  137. info = _read_archive_info(archive_name)
  138. archive_type = info.get("type", "")
  139. if request.method == "GET":
  140. return render_template("restore_confirm.html", archive_name=archive_name, info=info)
  141. # Trouver le job_id depuis le Run original pour pouvoir tracer la restauration
  142. original_run = Run.query.filter_by(archive_name=archive_name).first()
  143. restore_run_id = None
  144. if original_run:
  145. restore_run = Run(
  146. job_id=original_run.job_id,
  147. started_at=datetime.utcnow(),
  148. status="running",
  149. archive_name=archive_name,
  150. log_text="[RESTAURATION en cours…]",
  151. )
  152. db.session.add(restore_run)
  153. db.session.commit()
  154. restore_run_id = restore_run.id
  155. def _do_restore():
  156. with app.app_context():
  157. run = db.session.get(Run, restore_run_id) if restore_run_id else None
  158. try:
  159. backup_dir = app.config["YUNOHOST_BACKUP_DIR"]
  160. if archive_type == "custom_dir":
  161. from jobs.custom_dir import restore_custom_dir
  162. log = restore_custom_dir(archive_name, backup_dir)
  163. elif archive_type in ("mysql", "postgresql"):
  164. from jobs.db_dump import restore_db_dump
  165. log = restore_db_dump(archive_name, backup_dir)
  166. elif archive_type == "ynh_app":
  167. result = subprocess.run(
  168. ["sudo", "yunohost", "backup", "restore", archive_name,
  169. "--apps", "--force"],
  170. capture_output=True, text=True, timeout=3600,
  171. )
  172. log = (result.stdout + result.stderr).strip()
  173. if result.returncode != 0:
  174. raise RuntimeError(f"yunohost backup restore a échoué :\n{log}")
  175. elif archive_type == "ynh_system":
  176. result = subprocess.run(
  177. ["sudo", "yunohost", "backup", "restore", archive_name,
  178. "--system", "--force"],
  179. capture_output=True, text=True, timeout=3600,
  180. )
  181. log = (result.stdout + result.stderr).strip()
  182. if result.returncode != 0:
  183. raise RuntimeError(f"yunohost backup restore a échoué :\n{log}")
  184. else:
  185. raise NotImplementedError(
  186. f"Restauration non supportée pour le type '{archive_type}'."
  187. )
  188. if run:
  189. run.status = "success"
  190. run.finished_at = datetime.utcnow()
  191. run.log_text = f"[RESTAURATION]\n{log or 'OK'}"
  192. db.session.commit()
  193. except Exception as exc:
  194. app.logger.error(f"Restauration {archive_name} échouée : {exc}")
  195. if run:
  196. run.status = "error"
  197. run.finished_at = datetime.utcnow()
  198. run.log_text = f"[RESTAURATION]\n{exc}"
  199. db.session.commit()
  200. import threading
  201. threading.Thread(target=_do_restore, daemon=True).start()
  202. flash(f"Restauration de « {archive_name} » démarrée en arrière-plan.", "success")
  203. return redirect(url_for("index"))
  204. @app.route("/jobs/<int:job_id>/toggle", methods=["POST"])
  205. def job_toggle(job_id):
  206. job = db.get_or_404(Job, job_id)
  207. job.enabled = not job.enabled
  208. job.updated_at = datetime.utcnow()
  209. db.session.commit()
  210. if job.enabled:
  211. schedule_job(job)
  212. flash(f"Job « {job.name} » activé.", "success")
  213. else:
  214. remove_job(job.id)
  215. flash(f"Job « {job.name} » désactivé.", "info")
  216. return redirect(url_for("index"))
  217. def _save_job(job):
  218. f = request.form
  219. job_type = f.get("type", "")
  220. name = f.get("name", "").strip()
  221. if not name:
  222. flash("Le nom est requis.", "error")
  223. return render_template("job_form.html", job=job, ynh_apps=_get_ynh_apps(),
  224. destinations=Destination.query.filter_by(enabled=True).all())
  225. cfg = {}
  226. if job_type == "ynh_app":
  227. cfg = {"app_id": f.get("app_id", ""), "core_only": f.get("core_only") == "1"}
  228. elif job_type == "ynh_system":
  229. cfg = {}
  230. elif job_type in ("mysql", "postgresql"):
  231. dbname = f.get("db_database", "").strip()
  232. if not dbname:
  233. flash("Le nom de la base de données est requis.", "error")
  234. return render_template("job_form.html", job=job, ynh_apps=_get_ynh_apps(),
  235. destinations=Destination.query.filter_by(enabled=True).all())
  236. cfg = {"database": dbname}
  237. elif job_type == "custom_dir":
  238. source_path = f.get("source_path", "").strip().rstrip("/")
  239. if not source_path or not source_path.startswith("/"):
  240. flash("Le chemin source doit être un chemin absolu (ex: /opt/monapp).", "error")
  241. return render_template("job_form.html", job=job, ynh_apps=_get_ynh_apps(),
  242. destinations=Destination.query.filter_by(enabled=True).all())
  243. excludes = [e.strip() for e in f.get("excludes", "").splitlines() if e.strip()]
  244. restore_cfg = {}
  245. user_name = f.get("restore_user_name", "").strip()
  246. if user_name:
  247. restore_cfg["system_user"] = {
  248. "name": user_name,
  249. "home": f.get("restore_user_home", source_path).strip() or source_path,
  250. "shell": f.get("restore_user_shell", "/bin/false").strip() or "/bin/false",
  251. }
  252. service_name = f.get("restore_service_name", "").strip()
  253. if service_name:
  254. restore_cfg["systemd_service"] = {
  255. "name": service_name,
  256. "service_file": f.get("restore_service_file", "").strip(),
  257. }
  258. owner = f.get("restore_perm_owner", "").strip()
  259. mode = f.get("restore_perm_mode", "").strip()
  260. if owner or mode:
  261. restore_cfg["permissions"] = {}
  262. if owner:
  263. restore_cfg["permissions"]["owner"] = owner
  264. if mode:
  265. restore_cfg["permissions"]["mode"] = mode
  266. post_cmds = [c.strip() for c in f.get("restore_post_cmds", "").splitlines() if c.strip()]
  267. if post_cmds:
  268. restore_cfg["post_restore_commands"] = post_cmds
  269. cfg = {"source_path": source_path, "excludes": excludes, "restore": restore_cfg}
  270. if job is None:
  271. job = Job()
  272. db.session.add(job)
  273. dest_id = f.get("destination_id", "").strip()
  274. job.name = name
  275. job.type = job_type
  276. job.config_json = json.dumps(cfg)
  277. job.cron_expr = f.get("cron_expr", "0 3 * * *").strip()
  278. job.retention_mode = f.get("retention_mode", "count")
  279. job.retention_value = int(f.get("retention_value", 7))
  280. job.enabled = f.get("enabled") == "1"
  281. job.core_only = cfg.get("core_only", False)
  282. job.destination_id = int(dest_id) if dest_id else None
  283. job.updated_at = datetime.utcnow()
  284. db.session.commit()
  285. if job.enabled:
  286. schedule_job(job)
  287. else:
  288. remove_job(job.id)
  289. flash(f"Job « {job.name} » enregistré.", "success")
  290. return redirect(url_for("index"))
  291. # --- Destinations ------------------------------------------------------------
  292. @app.route("/destinations")
  293. def destinations_list():
  294. destinations = Destination.query.order_by(Destination.name).all()
  295. return render_template("destinations.html", destinations=destinations)
  296. @app.route("/destinations/new", methods=["GET", "POST"])
  297. def destination_new():
  298. if request.method == "POST":
  299. return _save_destination(None)
  300. return render_template("destination_form.html", dest=None)
  301. @app.route("/destinations/<int:dest_id>/edit", methods=["GET", "POST"])
  302. def destination_edit(dest_id):
  303. dest = db.get_or_404(Destination, dest_id)
  304. if request.method == "POST":
  305. return _save_destination(dest)
  306. pub_key = _get_pub_key(dest)
  307. return render_template("destination_form.html", dest=dest, pub_key=pub_key)
  308. @app.route("/destinations/<int:dest_id>/delete", methods=["POST"])
  309. def destination_delete(dest_id):
  310. dest = db.get_or_404(Destination, dest_id)
  311. db.session.delete(dest)
  312. db.session.commit()
  313. flash(f"Destination « {dest.name} » supprimée.", "success")
  314. return redirect(url_for("destinations_list"))
  315. @app.route("/destinations/<int:dest_id>/test", methods=["POST"])
  316. def destination_test(dest_id):
  317. dest = db.get_or_404(Destination, dest_id)
  318. from jobs.transfer import test_connection
  319. ok, msg = test_connection(dest, app.config["DATA_DIR"])
  320. flash(msg, "success" if ok else "error")
  321. return redirect(url_for("destinations_list"))
  322. @app.route("/archives/<path:archive_name>/transfer", methods=["POST"])
  323. def archive_transfer(archive_name):
  324. dest_id = request.form.get("destination_id", type=int)
  325. dest = db.get_or_404(Destination, dest_id)
  326. def _do_transfer():
  327. with app.app_context():
  328. try:
  329. from jobs.transfer import transfer_archive
  330. transfer_archive(archive_name, dest, app.config["YUNOHOST_BACKUP_DIR"],
  331. app.config["DATA_DIR"])
  332. app.logger.info(f"Transfert {archive_name} → {dest.remote_str} OK")
  333. except Exception as exc:
  334. app.logger.error(f"Transfert {archive_name} échoué : {exc}")
  335. import threading
  336. threading.Thread(target=_do_transfer, daemon=True).start()
  337. flash(f"Transfert de « {archive_name} » vers {dest.remote_str} démarré.", "success")
  338. return redirect(request.referrer or url_for("index"))
  339. def _save_destination(dest):
  340. f = request.form
  341. name = f.get("name", "").strip()
  342. host = f.get("host", "").strip()
  343. if not name or not host:
  344. flash("Nom et hôte sont requis.", "error")
  345. return render_template("destination_form.html", dest=dest)
  346. is_new = dest is None
  347. if is_new:
  348. dest = Destination()
  349. db.session.add(dest)
  350. dest.name = name
  351. dest.host = host
  352. dest.port = int(f.get("port", 22) or 22)
  353. dest.user = f.get("user", "root").strip() or "root"
  354. dest.remote_path = f.get("remote_path", "/home/yunohost.backup/archives").strip()
  355. dest.enabled = f.get("enabled") == "1"
  356. db.session.flush() # obtenir l'id si nouveau
  357. # Génération de la clé SSH si absente
  358. if not dest.key_name:
  359. from jobs.transfer import generate_key
  360. dest.key_name = generate_key(dest.name, app.config["DATA_DIR"])
  361. db.session.commit()
  362. flash(f"Destination « {dest.name} » enregistrée.", "success")
  363. return redirect(url_for("destination_edit", dest_id=dest.id))
  364. def _get_pub_key(dest):
  365. if not dest.key_name:
  366. return None
  367. from jobs.transfer import get_public_key
  368. return get_public_key(dest.key_name, app.config["DATA_DIR"])
  369. # --- Paramètres --------------------------------------------------------------
  370. _SETTING_KEYS = [
  371. "smtp_host", "smtp_port", "smtp_user", "smtp_password",
  372. "smtp_from", "smtp_to", "smtp_tls", "smtp_ssl",
  373. "notify_on_success", "notify_on_error",
  374. ]
  375. def _get_setting(key, default=""):
  376. s = Setting.query.filter_by(key=key).first()
  377. return s.value if s else default
  378. @app.route("/settings", methods=["GET", "POST"])
  379. def settings():
  380. if request.method == "POST":
  381. action = request.form.get("action")
  382. if action == "test_smtp":
  383. from notifications import send_test_email
  384. try:
  385. send_test_email(
  386. host=request.form.get("smtp_host", "").strip(),
  387. port=int(request.form.get("smtp_port", 587) or 587),
  388. user=request.form.get("smtp_user", "").strip(),
  389. password=request.form.get("smtp_password", ""),
  390. from_addr=request.form.get("smtp_from", "").strip(),
  391. to_addr=request.form.get("smtp_to", "").strip(),
  392. use_ssl=request.form.get("smtp_ssl") == "1",
  393. use_tls=request.form.get("smtp_tls") == "1",
  394. )
  395. flash("Email de test envoyé avec succès.", "success")
  396. except Exception as exc:
  397. flash(f"Échec du test SMTP : {exc}", "error")
  398. else:
  399. for key in _SETTING_KEYS:
  400. if key in ("smtp_tls", "smtp_ssl", "notify_on_success", "notify_on_error"):
  401. value = "1" if request.form.get(key) == "1" else "0"
  402. else:
  403. value = request.form.get(key, "").strip()
  404. s = Setting.query.filter_by(key=key).first()
  405. if s is None:
  406. s = Setting(key=key, value=value)
  407. db.session.add(s)
  408. else:
  409. s.value = value
  410. db.session.commit()
  411. flash("Paramètres enregistrés.", "success")
  412. return redirect(url_for("settings"))
  413. cfg = {k: _get_setting(k) for k in _SETTING_KEYS}
  414. # valeurs par défaut pour l'affichage
  415. cfg.setdefault("smtp_port", "587")
  416. cfg["smtp_tls"] = cfg.get("smtp_tls") or "1"
  417. cfg["smtp_ssl"] = cfg.get("smtp_ssl") or "0"
  418. cfg["notify_on_error"] = cfg.get("notify_on_error") or "1"
  419. return render_template("settings.html", cfg=cfg)
  420. # --- Routes internes (usage formulaires) -------------------------------------
  421. @app.route("/internal/databases/<db_type>")
  422. def internal_databases(db_type):
  423. """Liste les bases de données disponibles pour le formulaire job."""
  424. databases = []
  425. try:
  426. if db_type == "mysql":
  427. result = subprocess.run(
  428. ["sudo", "mysql", "--skip-column-names", "-e", "SHOW DATABASES;"],
  429. capture_output=True, text=True, timeout=10,
  430. )
  431. if result.returncode == 0:
  432. exclude = {"information_schema", "performance_schema", "mysql", "sys"}
  433. databases = [d.strip() for d in result.stdout.splitlines()
  434. if d.strip() and d.strip() not in exclude]
  435. elif db_type == "postgresql":
  436. result = subprocess.run(
  437. ["sudo", "-u", "postgres", "psql", "-Atc",
  438. "SELECT datname FROM pg_database WHERE datistemplate = false;"],
  439. capture_output=True, text=True, timeout=10,
  440. )
  441. if result.returncode == 0:
  442. databases = [d.strip() for d in result.stdout.splitlines() if d.strip()]
  443. except Exception:
  444. pass
  445. return jsonify(databases)
  446. # --- API v1 ------------------------------------------------------------------
  447. @app.route("/api/v1/health")
  448. def api_health():
  449. return jsonify({"status": "ok", "instance": app.config.get("INSTANCE_NAME")})
  450. @app.route("/api/v1/jobs")
  451. def api_jobs():
  452. jobs = Job.query.all()
  453. return jsonify([
  454. {
  455. "id": j.id,
  456. "name": j.name,
  457. "type": j.type,
  458. "cron_expr": j.cron_expr,
  459. "enabled": j.enabled,
  460. "retention_mode": j.retention_mode,
  461. "retention_value": j.retention_value,
  462. }
  463. for j in jobs
  464. ])
  465. @app.route("/api/v1/jobs/<int:job_id>/runs")
  466. def api_job_runs(job_id):
  467. runs = Run.query.filter_by(job_id=job_id).order_by(Run.started_at.desc()).limit(50).all()
  468. return jsonify([
  469. {
  470. "id": r.id,
  471. "started_at": r.started_at.isoformat() if r.started_at else None,
  472. "finished_at": r.finished_at.isoformat() if r.finished_at else None,
  473. "status": r.status,
  474. "archive_name": r.archive_name,
  475. "size_bytes": r.size_bytes,
  476. }
  477. for r in runs
  478. ])
  479. @app.route("/api/v1/jobs/<int:job_id>/run", methods=["POST"])
  480. def api_job_run(job_id):
  481. job = db.get_or_404(Job, job_id)
  482. from scheduler import _execute_job
  483. import threading
  484. threading.Thread(target=_execute_job, args=(job.id,), daemon=True).start()
  485. return jsonify({"status": "triggered", "job_id": job_id})
  486. @app.route("/api/v1/archives")
  487. def api_archives():
  488. backup_dir = app.config["YUNOHOST_BACKUP_DIR"]
  489. archives = []
  490. try:
  491. from jobs.utils import sudo_listdir, sudo_getsize, sudo_getmtime
  492. for fname in sorted(sudo_listdir(backup_dir)):
  493. if fname.endswith(".tar"):
  494. path = os.path.join(backup_dir, fname)
  495. archives.append({
  496. "name": fname[:-4],
  497. "size_bytes": sudo_getsize(path),
  498. "modified_at": datetime.utcfromtimestamp(sudo_getmtime(path)).isoformat(),
  499. })
  500. except OSError:
  501. pass
  502. return jsonify(archives)
  503. @app.route("/api/v1/archives/<name>", methods=["DELETE"])
  504. def api_archive_delete(name):
  505. backup_dir = app.config["YUNOHOST_BACKUP_DIR"]
  506. for ext in (".tar", ".info.json"):
  507. path = os.path.join(backup_dir, name + ext)
  508. if os.path.exists(path):
  509. os.remove(path)
  510. return jsonify({"status": "deleted", "name": name})