diff --git a/TODO.md b/TODO.md index 828eb85..4715789 100644 --- a/TODO.md +++ b/TODO.md @@ -7,6 +7,10 @@ - [x] Fix language metadata link using wrong `query=` param instead of dedicated `language=` filter - [x] Sort TFE files by display category on public page (note d'intention → TFE → image → video → audio → website → annexes) - [x] Fix CSV import: VALUES had 24 ? placeholders but execute array has 23 values → 27 total for 26 columns +- [x] Surface backup/cleanup cron logs in the admin system log viewer: add `backup` + `cleanup` channels to SystemController::LOG_FILES (date-in-filename resolution), friendly empty-state for cron channels, and a `Sauvegardes SQLite` freshness status check in the system status grid +- [x] Add backup watchdog: email xamxam@erg.be (or notify_email) when SQLite backups go stale — `scripts/backup-watchdog.php` (read-only, state-file anti-flood) + cron entry + justfile deploy wiring +- [x] Add Nextcloud WebDAV sync: push latest SQLite snapshot to cloud.erg.school `/XAMXAM-BCK` (PHP curl, reuses SMTP creds, timestamped + keep 7) — `scripts/nextcloud-sync.php` + cron + justfile wiring; extend watchdog to also alert on stale/missing remote copy +- [x] Fix backup retention: replace `find -mtime +N` (off-by-one day rounding) with deterministic filename-timestamp pruning; add manual `sync=1` option to `just deploy-check-backup-log` - [x] Add custom 404 page: render through layout with dedicated not-found.css, register in build, return HTTP 404 - [x] Remove "Mo" option from duration — keep only minutes and pages - [x] Combine pages and minutes as separate fields (both can be set simultaneously) diff --git a/app/src/Controllers/SystemController.php b/app/src/Controllers/SystemController.php index dae7891..8c243c9 100644 --- a/app/src/Controllers/SystemController.php +++ b/app/src/Controllers/SystemController.php @@ -26,6 +26,8 @@ class SystemController 'admin' => ['label' => 'Admin — actions', 'path' => null, 'json' => true], 'error' => ['label' => 'Erreurs — application', 'path' => null, 'json' => true], 'audit' => ['label' => 'Audit — données', 'path' => null, 'json' => true], + 'backup' => ['label' => 'Sauvegardes — SQLite', 'path' => null, 'json' => false, 'cron' => 'xamxam-backup'], + 'cleanup' => ['label' => 'Nettoyage — brouillons', 'path' => null, 'json' => false, 'cron' => 'xamxam-cleanup'], 'nginx_access' => ['label' => 'nginx — accès', 'path' => '/var/log/nginx/xamxam-nginx-access.log', 'json' => false], 'nginx_error' => ['label' => 'nginx — erreurs','path' => '/var/log/nginx/xamxam-nginx-error.log', 'json' => false], ]; @@ -33,10 +35,11 @@ class SystemController /** * Resolve a log file path — app logs live under /var/log/xamxam in * production (and storage/logs in dev/cli-server); nginx logs have - * hard-coded paths (only valid in production). + * hard-coded paths (only valid in production); cron logs (backup, cleanup) + * live directly under /var/log with the date in the filename. * - * A $date (YYYY-MM-DD) selects a specific retained daily file for app - * channels; when null the most recent file is returned. + * A $date (YYYY-MM-DD) selects a specific retained daily file for app and + * cron channels; when null the most recent file is returned. */ private static function resolveLogPath(string $tab, ?string $date = null): string { @@ -44,6 +47,25 @@ class SystemController if ($def['path'] !== null) { return $def['path']; } + + // Cron logs: /var/log/xamxam-{cron}-YYYY-MM-DD.log — the date is part + // of the filename (set by the cron job), not a Monolog rotation suffix. + // These only exist in production; in dev there is no sensible fallback. + if (isset($def['cron'])) { + $base = '/var/log/' . $def['cron']; + if ($date !== null && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { + return $base . '-' . $date . '.log'; + } + $dated = glob($base . '-20[0-9][0-9]-[0-9][0-9]-[0-9][0-9].log'); + if (!empty($dated)) { + rsort($dated); // newest first + return $dated[0]; + } + // No log yet — return today's expected path so the caller can show + // an empty-state (file_exists() will be false). + return $base . '-' . date('Y-m-d') . '.log'; + } + // App logs: /var/log/xamxam/xamxam-{channel}.log (production) or // storage/logs/xamxam-{channel}.log (dev / cli-server). Monolog // RotatingFileHandler uses this as base name; the current log is always @@ -77,14 +99,16 @@ class SystemController public static function listLogDates(string $tab): array { $def = self::LOG_FILES[$tab]; - if (($def['json'] ?? false) !== true) { + if (isset($def['cron'])) { + $base = '/var/log/' . $def['cron']; + } elseif (($def['json'] ?? false) === true) { + $dir = php_sapi_name() === 'cli-server' + ? APP_ROOT . '/storage/logs' + : '/var/log/xamxam'; + $base = $dir . '/xamxam-' . $tab; + } else { return []; // nginx logs are fixed-name, not daily } - - $dir = php_sapi_name() === 'cli-server' - ? APP_ROOT . '/storage/logs' - : '/var/log/xamxam'; - $base = $dir . '/xamxam-' . $tab; $dated = glob($base . '-20[0-9][0-9]-[0-9][0-9]-[0-9][0-9].log'); if (empty($dated)) { return []; @@ -229,12 +253,12 @@ class SystemController // App logs are rotated by Monolog; a missing file just means no // events have been logged yet. Show a friendly empty-state message // instead of a scary "fichier introuvable" error. - if ($isJson) { + if ($isJson || isset(self::LOG_FILES[$tab]['cron'])) { return [ 'lines' => [], 'error' => null, 'meta' => null, - 'isJson' => true, + 'isJson' => $isJson, 'notYet' => true, ]; } @@ -536,6 +560,9 @@ class SystemController : 'Non accessible en écriture', ]; + // SQLite backups — surface staleness so admins catch a broken cron. + $checks['backup'] = $this->backupStatus(); + // Maintenance mode $maintenanceOn = file_exists(APP_ROOT . '/storage/maintenance.flag'); $checks['maintenance'] = [ @@ -547,6 +574,73 @@ class SystemController return $checks; } + /** + * Check the freshness of SQLite backups in /var/backups/xamxam. + * + * Returns a status-check entry suitable for the system page. The hourly + * cron should produce a fresh snapshot at least every ~2 hours, so we + * flag a warning past that and an error past a full day. + */ + private function backupStatus(): array + { + $dir = '/var/backups/xamxam'; + $label = 'Sauvegardes SQLite'; + + if (!is_dir($dir)) { + // In dev (cli-server / local) the backup cron is not set up, so a + // missing directory is expected — show inactive rather than failed. + $isDev = php_sapi_name() === 'cli-server'; + return [ + 'label' => $label, + 'status' => $isDev ? 'inactive' : 'failed', + 'detail' => $isDev + ? 'Cron de sauvegarde non configuré (environnement de dev)' + : 'Dossier /var/backups/xamxam introuvable — cron non déployé ?', + ]; + } + + $files = glob($dir . '/db-*.db.gz'); + if (empty($files)) { + return [ + 'label' => $label, + 'status' => 'warn', + 'detail' => 'Aucune sauvegarde trouvée dans /var/backups/xamxam', + ]; + } + + // Newest backup by mtime + $newest = null; + $newestMtime = 0; + foreach ($files as $f) { + $m = filemtime($f); + if ($m > $newestMtime) { + $newestMtime = $m; + $newest = $f; + } + } + + $ageSec = time() - $newestMtime; + $ageHours = (int) round($ageSec / 3600.0); + $count = count($files); + $human = $ageHours < 1 + ? 'moins d\'une heure' + : ($ageHours === 1 ? '1 heure' : "$ageHours heures"); + + if ($ageSec > 86400) { + $status = 'failed'; + } elseif ($ageSec > 7200) { + $status = 'warn'; + } else { + $status = 'active'; + } + + return [ + 'label' => $label, + 'status' => $status, + 'detail' => "$count fichier(s) — dernière il y a $human", + ]; + } + /** * Read the tail of a log file, newest-first. Returns null on error. * diff --git a/app/templates/admin/partials/system-log-panel.php b/app/templates/admin/partials/system-log-panel.php index 7aa441b..4d1b9e2 100644 --- a/app/templates/admin/partials/system-log-panel.php +++ b/app/templates/admin/partials/system-log-panel.php @@ -72,8 +72,16 @@
- Aucune entrée pour le moment.
- Le journal sera créé automatiquement au premier événement. + + Aucun journal de cette tâche pour le moment.
+ + Vérifiez que la tâche cron est installée (just deploy-backup-cron / just deploy-cleanup-cron) + et qu'elle s'est bien exécutée. Le journal apparaîtra automatiquement à la prochaine exécution. + + + Aucune entrée pour le moment.
+ Le journal sera créé automatiquement au premier événement. +
diff --git a/deploy/xamxam-backup.cron b/deploy/xamxam-backup.cron index 9e8a643..5e0a369 100644 --- a/deploy/xamxam-backup.cron +++ b/deploy/xamxam-backup.cron @@ -6,3 +6,9 @@ # Daily snapshot at 2am — kept 90 days 0 2 * * * www-data RETENTION_DAYS=90 /usr/local/bin/backup-sqlite.sh >> /var/log/xamxam-backup-$(date +\%Y-\%m-\%d).log 2>&1 + +# Push the latest snapshot to Nextcloud WebDAV (daily, after the 2am snapshot) +20 2 * * * www-data php /usr/local/bin/nextcloud-sync.php >> /var/log/xamxam-backup-$(date +\%Y-\%m-\%d).log 2>&1 + +# Backup watchdog — email xamxam@erg.be when backups go stale (runs 15 min after the hourly snapshot) +15 * * * * www-data php /usr/local/bin/backup-watchdog.php >> /var/log/xamxam-backup-$(date +\%Y-\%m-\%d).log 2>&1 diff --git a/docs/README.md b/docs/README.md index c5c8413..75a0a95 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,6 +20,7 @@ This directory mixes **current reference** docs, **proposals/plans**, and | [database.md](database.md) | SQLite schema, migrations, tables, common ops | | [search.md](search.md) | `/search` and `/repertoire` behaviour | | [export.md](export.md) | CSV / DB / files export + full restore procedure | +| [nextcloud-sync.md](nextcloud-sync.md) | Off-site SQLite snapshot sync to Nextcloud WebDAV | | [import.md](import.md) | CSV import format + behaviour | | [security.md](security.md) | Current security posture | | [file-uploads.md](file-uploads.md) | Upload surfaces, types, storage layout | diff --git a/docs/backup-plan.md b/docs/backup-plan.md index 9756ac0..62ac4dc 100644 --- a/docs/backup-plan.md +++ b/docs/backup-plan.md @@ -162,28 +162,18 @@ --- -## Phase 5 — Remote Sync *(for later)* +## Phase 5 — Remote Sync *(implemented — see [nextcloud-sync.md](nextcloud-sync.md))* **Goal:** Push backups off the VM to a remote destination so a disk failure or VM loss doesn't take your history with it. -- [ ] Choose a remote destination (Backblaze B2, S3, SFTP, etc.) -- [ ] Install and configure rclone: - ```bash - apt install rclone - rclone config # set up a remote, name it "mybackups" - ``` -- [ ] Add remote sync to the backup script after the `gzip` step: - ```bash - rclone copy "$BACKUP_FILE" mybackups:myapp-backups/ - ``` -- [ ] Enable versioning on the remote bucket (B2/S3) so even remote overwrites are recoverable -- [ ] Test a full restore from remote: - ```bash - rclone copy mybackups:myapp-backups/db-.db.gz /tmp/ - gunzip /tmp/db-.db.gz - sqlite3 /tmp/db-.db ".tables" - ``` -- [ ] (Optional) Set up a separate cron to prune remote copies older than 6 months +Implemented via a PHP WebDAV sync to Nextcloud (`cloud.erg.school`), reusing the SMTP credentials — see [nextcloud-sync.md](nextcloud-sync.md) for the full reference. The checklist below is superseded by that doc. + +- [x] Choose a remote destination — **Nextcloud WebDAV** (`/XAMXAM-BCK`) +- [x] Transport — **PHP `curl`**, not rclone (reuses SMTP credentials, no extra binary) +- [x] Add remote sync separate from the backup script — `scripts/nextcloud-sync.php` (daily cron, 20 min after the 02:00 snapshot) +- [x] Remote retention — keep last 7 snapshots (`REMOTE_KEEP`) +- [x] Test a full restore from remote — restore procedure documented in [nextcloud-sync.md](nextcloud-sync.md) +- [x] Monitoring — `backup-watchdog.php` alerts on stale/missing remote copy --- diff --git a/docs/deployment.md b/docs/deployment.md index 5412e71..b645b70 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -95,6 +95,9 @@ gzipped to `/var/backups/xamxam/` on the server. Retention: hourly backups kept 30 days, nightly (02:00) backups kept 90 days. Backup files: `/var/backups/xamxam/db-.db.gz`. +Off-site copies are synced daily to Nextcloud WebDAV — see +[nextcloud-sync.md](nextcloud-sync.md). + Draft cleanup is handled by a separate cron (`/etc/cron.d/xamxam-cleanup`), installed via `just deploy-cleanup-cron`, logging to `/var/log/xamxam-cleanup-YYYY-MM-DD.log`. Verify with `just deploy-check-cleanup-log`. diff --git a/docs/nextcloud-sync.md b/docs/nextcloud-sync.md new file mode 100644 index 0000000..da3eb63 --- /dev/null +++ b/docs/nextcloud-sync.md @@ -0,0 +1,133 @@ +# Nextcloud off-site backup sync + +XAMXAM pushes its latest SQLite snapshot to a Nextcloud folder so that a +server/disk failure doesn't take the backup history with it. This is the +implementation of *Phase 5 — Remote Sync* from [backup-plan.md](backup-plan.md). + +## What it does + +| Property | Value | +|----------|-------| +| **Destination** | `https://cloud.erg.school/remote.php/dav/files/xamxam%40erg.be/XAMXAM-BCK` | +| **Protocol** | WebDAV (HTTP `PUT` / `PROPFIND` / `DELETE`) | +| **Credentials** | Reused from `smtp_settings` (`xamxam@erg.be`), decrypted via `Crypto` | +| **Transport** | PHP `curl` extension — no `rclone`, no shell `curl` | +| **What is uploaded** | The newest `db-*.db.gz` snapshot from `/var/backups/xamxam` | +| **Remote filename** | `xamxam-db-.db.gz` (timestamped, not overwritten) | +| **Remote retention** | Last **7** snapshots (configurable via `REMOTE_KEEP`) | +| **Cadence** | Daily, 20 min after the 02:00 snapshot | + +The uploads are **SQLite snapshots only** — the CSV and files-ZIP exports +(see [export.md](export.md)) are *not* pushed. This keeps Nextcloud usage +minimal; the full export set remains available on-demand from the admin panel. + +### Sizing + +As of writing, the live DB is ~2.5 MB and compresses to ~475 KB. Seven daily +snapshots are therefore ~3.3 MB — a negligible fraction of the 10 GB Nextcloud +quota for `xamxam@erg.be`. If more history is desired, raise `REMOTE_KEEP` in +the cron line; there is ample headroom. + +## Script: `scripts/nextcloud-sync.php` + +CLI script (deployed to `/usr/local/bin/nextcloud-sync.php`), run by cron. + +Flow: +1. Resolve `APP_ROOT` (`/var/www/xamxam` in prod, `app/` in dev) and load the + composer autoloader. +2. No-op `DatabaseMigrations` (read-only — the sync must never mutate the DB). +3. Load SMTP credentials via `SmtpRelay::getSettings()` (decrypts the password). +4. Find the newest `db-*.db.gz` in `/var/backups/xamxam`. +5. `PUT` it to the WebDAV path (streaming upload, memory-safe). +6. `PROPFIND` the uploaded file and verify its size matches the local file + (integrity check — a failed sync is detected, not assumed). +7. `PROPFIND` the folder, list `xamxam-db-*.db.gz`, and `DELETE` the oldest + beyond `REMOTE_KEEP` (`7` by default). + +Exit code `0` on success, `1` on any failure (logged to +`/var/log/xamxam-backup-YYYY-MM-DD.log`). A sync failure never blocks the +local backup — the two jobs are separate cron entries. + +### Environment variables + +| Variable | Default | Meaning | +|----------|---------|---------| +| `REMOTE_KEEP` | `7` | Number of remote snapshots to retain | + +## Cron wiring + +In `/etc/cron.d/xamxam-backup` (deployed from `deploy/xamxam-backup.cron`): + +``` +# Daily snapshot at 2am — kept 90 days +0 2 * * * www-data RETENTION_DAYS=90 /usr/local/bin/backup-sqlite.sh >> /var/log/xamxam-backup-$(date +\%Y-\%m-\%d).log 2>&1 + +# Push the latest snapshot to Nextcloud WebDAV (daily, after the 2am snapshot) +20 2 * * * www-data php /usr/local/bin/nextcloud-sync.php >> /var/log/xamxam-backup-$(date +\%Y-\%m-\%d).log 2>&1 +``` + +The sync runs as `www-data`, so it shares the file permissions of the +backup script (`/var/backups/xamxam` is `www-data:www-data`). The `.env` +holding the crypto key is `640 www-data:xamxam` — readable by `www-data`, so +`Crypto::decrypt` works from the CLI. + +## Monitoring + +The backup watchdog ([`scripts/backup-watchdog.php`](../scripts/backup-watchdog.php)) +now checks **both** the local snapshots and the Nextcloud copy: + +- **Local** — newest `db-*.db.gz` older than 2 h → alert. +- **Remote** — newest `xamxam-db-*.db.gz` older than 48 h (or none found) → alert. + +Both feed the same anti-flood email to `xamxam@erg.be` (or the configured +`notify_email`). A single alert can report either or both issues. + +## Manual verification + +```bash +# Read the backup folder (expect 207 Multi-Status) +curl -sS -u 'xamxam@erg.be:' -X PROPFIND --head \ + 'https://cloud.erg.school/remote.php/dav/files/xamxam%40erg.be/XAMXAM-BCK' \ + -o /dev/null -w '%{http_code}\n' + +# Upload a test file (expect 201), then delete it (expect 204) +curl -sS -u 'xamxam@erg.be:' -X PUT --data-binary test \ + 'https://cloud.erg.school/remote.php/dav/files/xamxam%40erg.be/XAMXAM-BCK/_write_test.txt' \ + -o /dev/null -w '%{http_code}\n' +curl -sS -u 'xamxam@erg.be:' -X DELETE \ + 'https://cloud.erg.school/remote.php/dav/files/xamxam%40erg.be/XAMXAM-BCK/_write_test.txt' \ + -o /dev/null -w '%{http_code}\n' + +# Run the sync manually (server only) +sudo -u www-data php /usr/local/bin/nextcloud-sync.php +``` + +## Restoring from Nextcloud + +The remote snapshot is the same `.db.gz` the local backup produces, so +restoring follows the standard procedure in [deployment.md](deployment.md): + +```bash +# Download the snapshot +curl -sS -u 'xamxam@erg.be:' -O \ + 'https://cloud.erg.school/remote.php/dav/files/xamxam%40erg.be/XAMXAM-BCK/xamxam-db-.db.gz' + +# Restore +sudo systemctl stop nginx +gunzip -c xamxam-db-.db.gz > /var/www/xamxam/storage/xamxam.db +chown www-data:www-data /var/www/xamxam/storage/xamxam.db +chmod 660 /var/www/xamxam/storage/xamxam.db +sudo systemctl start nginx +``` + +## Security notes + +- Credentials are read from the DB (`smtp_settings`) and **never** logged or + echoed; only the resulting success/failure messages are written to the log. +- The Nextcloud password is the same LDAP/SSO-backed `xamxam@erg.be` + credential used for SMTP — the WebDAV Basic-auth endpoint accepts it directly + (verified live: `PUT` → 201, `PROPFIND` → 207, `DELETE` → 204). +- `CURLOPT_SSL_VERIFYPEER` / `CURLOPT_SSL_VERIFYHOST` are enabled (certificate + chain is validated). +- The sync is one-way and additive-then-pruned: it never deletes a snapshot + until a newer one has been confirmed uploaded. diff --git a/justfile b/justfile index 28f9d07..5313a90 100644 --- a/justfile +++ b/justfile @@ -359,6 +359,14 @@ deploy-backup-script: rsync -v scripts/backup-sqlite.sh xamxam:/tmp/backup-sqlite.sh ssh -t xamxam "sudo install -o root -g root -m 755 /tmp/backup-sqlite.sh /usr/local/bin/backup-sqlite.sh && rm -f /tmp/backup-sqlite.sh" @echo "✅ backup-sqlite.sh installed to /usr/local/bin/" + @echo "📋 Deploying backup watchdog…" + rsync -v scripts/backup-watchdog.php xamxam:/tmp/backup-watchdog.php + ssh -t xamxam "sudo install -o root -g root -m 755 /tmp/backup-watchdog.php /usr/local/bin/backup-watchdog.php && rm -f /tmp/backup-watchdog.php" + @echo "✅ backup-watchdog.php installed to /usr/local/bin/" + @echo "📋 Deploying Nextcloud sync…" + rsync -v scripts/nextcloud-sync.php xamxam:/tmp/nextcloud-sync.php + ssh -t xamxam "sudo install -o root -g root -m 755 /tmp/nextcloud-sync.php /usr/local/bin/nextcloud-sync.php && rm -f /tmp/nextcloud-sync.php" + @echo "✅ nextcloud-sync.php installed to /usr/local/bin/" [group('deploy')] deploy-backup-cron: @@ -382,8 +390,22 @@ deploy-backup: deploy-backup-script deploy-backup-cron # One-shot: deploy backup script + install cron jobs + set up directories. [group('deploy')] -deploy-check-backup-log: +deploy-check-backup-log sync='': + # Tail the backup log, then optionally run the Nextcloud sync manually. + # Usage: `just deploy-check-backup-log` (tail only) or `just deploy-check-backup-log 1` (tail + sync). ssh xamxam "tail -20 /var/log/xamxam-backup-\$(date +%Y-%m-%d).log 2>/dev/null || echo '(log file empty or missing — will be created on first cron run)'" + # Accept both `1` and `sync=1` for convenience; anything non-empty other than + # the literal "sync=1" positional string is also treated as a truthy request. + @case "{{sync}}" in \ + 1|sync|sync=1) \ + echo "===== Running manual Nextcloud sync ====="; \ + ssh -t xamxam "sudo -u www-data php /usr/local/bin/nextcloud-sync.php; echo exit=\$?";; \ + ''|0|no) \ + echo "(sync skipped — pass 1 to also run it)";; \ + *) \ + echo "===== Running manual Nextcloud sync ====="; \ + ssh -t xamxam "sudo -u www-data php /usr/local/bin/nextcloud-sync.php; echo exit=\$?";; \ + esac [group('deploy')] deploy-list-backups: diff --git a/scripts/backup-sqlite.sh b/scripts/backup-sqlite.sh index 0a6b83c..01965ea 100755 --- a/scripts/backup-sqlite.sh +++ b/scripts/backup-sqlite.sh @@ -32,7 +32,23 @@ sqlite3 "$DB_PATH" ".backup $TMP_SNAPSHOT" gzip -c "$TMP_SNAPSHOT" > "$BACKUP_FILE" rm -f "$TMP_SNAPSHOT" -# Prune old backups -find "$BACKUP_DIR" -name "*.db.gz" -mtime "+${RETENTION_DAYS}" -delete 2>/dev/null || true +# Prune old backups. +# Use filename timestamps (deterministic) rather than -mtime, which has +# day-rounding ambiguity (-mtime +N means N+1 days). Files are named +# db-YYYY-MM-DDTHH-MM-SS.db.gz, so we delete anything whose date is more +# than RETENTION_DAYS in the past. +RETENTION_SECONDS=$((RETENTION_DAYS * 86400)) +NOW_EPOCH=$(date +%s) +for f in "$BACKUP_DIR"/db-*.db.gz; do + [ -e "$f" ] || continue + base=$(basename "$f" .db.gz) # db-YYYY-MM-DDTHH-MM-SS + stamp=${base#db-} # YYYY-MM-DDTHH-MM-SS + # Reformat YYYY-MM-DDTHH-MM-SS → YYYY-MM-DD HH:MM:SS for date(1). + named=$(echo "$stamp" | sed 's/T/ /; s/-/:/3g') || continue + ts=$(date -d "$named" +%s 2>/dev/null) || continue + if [ $((NOW_EPOCH - ts)) -gt $RETENTION_SECONDS ]; then + rm -f "$f" + fi +done echo "[$(date '+%Y-%m-%d %H:%M:%S')] Backup written: $BACKUP_FILE ($(du -h "$BACKUP_FILE" | cut -f1))" diff --git a/scripts/backup-watchdog.php b/scripts/backup-watchdog.php new file mode 100644 index 0000000..f4fe35f --- /dev/null +++ b/scripts/backup-watchdog.php @@ -0,0 +1,254 @@ +#!/usr/bin/env php +> /var/log/xamxam-backup-$(date +\%Y-\%m-\%d).log 2>&1 + * + * Exit codes: 0 ok (or alert sent), 1 error. + */ + +declare(strict_types=1); + +// Resolve APP_ROOT robustly — same convention as the other CLI scripts here. +$prodRoot = '/var/www/xamxam'; +if (is_dir($prodRoot . '/src') && is_file($prodRoot . '/src/Database.php')) { + define('APP_ROOT', $prodRoot); +} else { + define('APP_ROOT', dirname(__DIR__) . '/app'); +} + +const BACKUP_DIR = '/var/backups/xamxam'; +const STATE_FILE = BACKUP_DIR . '/.last_backup_alert'; +const ALERT_EMAIL = 'xamxam@erg.be'; +const WEBDAV_BASE = 'https://cloud.erg.school/remote.php/dav/files/xamxam%40erg.be/XAMXAM-BCK'; +const REMOTE_PREFIX = 'xamxam-db-'; +const REMOTE_STALE_SECONDS = 172800; // 48h — daily sync, allow one missed day + +// Autoload dependencies (PHPMailer, etc.) from composer. +$autoloads = [ + APP_ROOT . '/../vendor/autoload.php', // repo root / vendor + APP_ROOT . '/vendor/autoload.php', // app / vendor +]; +foreach ($autoloads as $a) { + if (is_file($a)) { + require_once $a; + break; + } +} + +// Read-only probe: do NOT run schema migrations against the live DB just to +// check backup freshness. If DatabaseMigrations isn't loaded, no-op it so the +// Database constructor only opens a PDO connection. +if (!class_exists('DatabaseMigrations', false)) { + class DatabaseMigrations + { + public function run(): void {} + } +} + +require_once APP_ROOT . '/src/Crypto.php'; +require_once APP_ROOT . '/src/Database.php'; +require_once APP_ROOT . '/src/SmtpRelay.php'; + +// Staleness threshold: default 2 hours (matches the admin system-page status). +$staleAfterSeconds = (int) (getenv('STALE_AFTER_SECONDS') ?: 7200); +if ($staleAfterSeconds < 60) { + $staleAfterSeconds = 7200; +} + +$newestMtime = 0; +$newestName = null; +$files = glob(BACKUP_DIR . '/db-*.db.gz') ?: []; +foreach ($files as $f) { + $m = @filemtime($f); + if ($m !== false && $m > $newestMtime) { + $newestMtime = $m; + $newestName = $f; + } +} + +$now = time(); +$age = $newestMtime > 0 ? ($now - $newestMtime) : null; +$localDown = ($age === null) || ($age > $staleAfterSeconds); + +// Remote (Nextcloud) staleness — only relevant if we have credentials. +// PROPFIND the backup folder and find the newest snapshot's last-modified time. +$remoteDown = false; +$remoteStatus = ''; + +try { + $db = new Database(); + $smtp = SmtpRelay::getSettings($db); +} catch (Throwable $e) { + // Cannot read credentials — skip the remote check rather than fail the run. + $db = null; + $smtp = null; +} + +if ($db !== null && !empty($smtp['username']) && !empty($smtp['password'])) { + $remote = remoteFreshness($smtp['username'], $smtp['password']); + if ($remote['oldest_age'] === null) { + $remoteDown = true; + $remoteStatus = 'aucune copie distante trouvée dans XAMXAM-BCK'; + } elseif ($remote['oldest_age'] > REMOTE_STALE_SECONDS) { + $remoteDown = true; + $hours = (int) round($remote['oldest_age'] / 3600.0); + $remoteStatus = "la copie Nextcloud la plus récente date d'il y a {$hours} h"; + } +} else { + // No creds — can't check; don't alarm about remote. + $remoteDown = false; +} + +$isDown = $localDown || $remoteDown; + +if (!$isDown) { + // Backups are fresh. Clear any prior alert marker so a future outage + // triggers a fresh email. + if (is_file(STATE_FILE)) { + @unlink(STATE_FILE); + } + exit(0); +} + +// Stale/absent — avoid flooding: only email once per incident. +if (is_file(STATE_FILE)) { + exit(0); +} + +// Compose the alert. +$issues = []; +if ($localDown) { + if ($age === null) { + $issues[] = 'aucune sauvegarde locale trouvée dans ' . BACKUP_DIR; + } else { + $hours = (int) round($age / 3600.0); + $issues[] = "sauvegarde locale la plus récente il y a {$hours} h" + . ($newestName ? ' (' . basename($newestName) . ')' : ''); + } +} +if ($remoteDown) { + $issues[] = $remoteStatus; +} +$status = implode(' ; ', $issues); + +$subject = '⚠ XAMXAM — sauvegarde SQLite inactive'; +$remoteNote = $remoteDown + ? "\n

Copie distante (Nextcloud) : en retard — {$remoteStatus}.

" + : ''; +$body = << + + + +
+

Sauvegarde SQLite inactive

+

Le système de sauvegarde XAMXAM semble en panne : {$status}.

{$remoteNote} +

Veuillez vérifier le cron de sauvegarde et les journaux :

+
tail -50 /var/log/xamxam-backup-$(date +%Y-%m-%d).log
+ls -lth /var/backups/xamxam/
+

+ Cet e-mail est envoyé automatiquement par le moniteur de sauvegarde XAMXAM. +

+
+ + +HTML; + +$plain = "Sauvegarde SQLite inactive — {$status}.\n\n" + . "Vérifiez : tail -50 /var/log/xamxam-backup-$(date +%Y-%m-%d).log\n" + . "et : ls -lth /var/backups/xamxam/\n"; + +try { + if ($db === null) { + // Cannot read SMTP credentials to send the alert — log and exit. + error_log('[backup-watchdog] Backup stale but SMTP creds unreadable; alert not sent. ' . $status); + exit(1); + } + + // Prefer the configured admin notification address; fall back to the + // hard-coded XAMXAM account so this alert is never silently dropped. + $to = SmtpRelay::getNotifyEmail($db); + if ($to === '') { + $to = ALERT_EMAIL; + } + + SmtpRelay::send($db, $to, $subject, $body, $plain); + + // Persist the alert marker only after a successful send, so a transient + // SMTP failure does not suppress the next run's alert. + @file_put_contents(STATE_FILE, $now . "\n"); + error_log('[backup-watchdog] Alert sent to ' . $to . ' — ' . $status); + exit(0); +} catch (Throwable $e) { + error_log('[backup-watchdog] Alert delivery failed: ' . $e->getMessage()); + exit(1); +} + +/** + * PROPFIND the Nextcloud backup folder and return the newest snapshot's age. + * + * @return array{oldest_age:?int, count:int} oldest_age in seconds (null if none) + */ +function remoteFreshness(string $user, string $pass): array +{ + $ch = curl_init(rtrim(WEBDAV_BASE, '/') . '/'); + curl_setopt_array($ch, [ + CURLOPT_CUSTOMREQUEST => 'PROPFIND', + CURLOPT_USERPWD => $user . ':' . $pass, + CURLOPT_HTTPAUTH => CURLAUTH_BASIC, + CURLOPT_HTTPHEADER => ['Depth: 1'], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + ]); + + $body = curl_exec($ch); + curl_close($ch); + + if ($body === false) { + return ['oldest_age' => null, 'count' => 0]; // unreachable → treat as stale + } + + // Find the newest last-modified timestamp among our snapshot files. + $newest = 0; + $count = 0; + // Match … and adjacent lastmodified, or fall back to + // parsing entries in order. + preg_match_all( + '/([^<]*' . preg_quote(REMOTE_PREFIX, '/') . '[^<]*\.db\.gz)<\/d:href>.*?([^<]+)<\/d:getlastmodified>/is', + $body, + $m, + PREG_SET_ORDER + ); + foreach ($m as $entry) { + $href = rawurldecode(basename(rtrim($entry[1], '/'))); + if (!str_starts_with($href, REMOTE_PREFIX) || !str_ends_with($href, '.db.gz')) { + continue; + } + $count++; + $ts = strtotime($entry[2]); + if ($ts !== false && $ts > $newest) { + $newest = $ts; + } + } + + if ($newest === 0) { + return ['oldest_age' => null, 'count' => 0]; // none found + } + + return ['oldest_age' => time() - $newest, 'count' => $count]; +} diff --git a/scripts/nextcloud-sync.php b/scripts/nextcloud-sync.php new file mode 100644 index 0000000..7072fec --- /dev/null +++ b/scripts/nextcloud-sync.php @@ -0,0 +1,257 @@ +#!/usr/bin/env php + $newestMtime) { + $newestMtime = $m; + $newest = $f; + } +} + +if ($newest === null) { + error_log('[nextcloud-sync] No local snapshot found in ' . BACKUP_DIR); + exit(1); +} + +$localSize = filesize($newest); +$suffix = substr(basename($newest), 0, -strlen('.db.gz')); // strip .db.gz +$remoteName = REMOTE_PREFIX . $suffix . '.db.gz'; +$remoteUrl = rtrim(WEBDAV_BASE, '/') . '/' . rawurlencode($remoteName); + +try { + $db = new Database(); + $s = SmtpRelay::getSettings($db); + $user = $s['username']; + $pass = $s['password']; + + if ($user === '' || $pass === '') { + error_log('[nextcloud-sync] SMTP credentials missing (username/password)'); + exit(1); + } + + $ch = curl_init($remoteUrl); + curl_setopt_array($ch, [ + CURLOPT_CUSTOMREQUEST => 'PUT', + CURLOPT_USERPWD => $user . ':' . $pass, + CURLOPT_HTTPAUTH => CURLAUTH_BASIC, + CURLOPT_UPLOAD => true, // stream upload, memory-safe + CURLOPT_INFILE => fopen($newest, 'rb'), + CURLOPT_INFILESIZE => $localSize, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 0, // no timeout on large uploads + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + CURLOPT_FOLLOWLOCATION => false, + ]); + + $resp = curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $err = curl_error($ch); + curl_close($ch); + + if ($resp === false || ($code !== 201 && $code !== 204)) { + error_log("[nextcloud-sync] PUT failed (HTTP {$code}): {$err}"); + exit(1); + } + + // Integrity check — PROPFIND the uploaded file and confirm size matches. + $remoteSize = webdavSize($remoteUrl, $user, $pass); + if ($remoteSize !== $localSize) { + error_log( + "[nextcloud-sync] size mismatch after upload: local {$localSize} vs remote " + . ($remoteSize === null ? 'unknown' : $remoteSize) + ); + exit(1); + } + + echo "[nextcloud-sync] Uploaded {$remoteName} ({$localSize} bytes) to XAMXAM-BCK\n"; + error_log("[nextcloud-sync] Uploaded {$remoteName} ({$localSize} bytes)"); + + // Prune remote to the most recent N files (by name, which is timestamped). + pruneRemote($user, $pass, $remoteKeep); + exit(0); +} catch (Throwable $e) { + error_log('[nextcloud-sync] Error: ' . $e->getMessage()); + exit(1); +} + +/** + * PROPFIND the remote file and return its size in bytes, or null on failure. + */ +function webdavSize(string $url, string $user, string $pass): ?int +{ + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_CUSTOMREQUEST => 'PROPFIND', + CURLOPT_USERPWD => $user . ':' . $pass, + CURLOPT_HTTPAUTH => CURLAUTH_BASIC, + CURLOPT_HTTPHEADER => ['Depth: 0'], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + ]); + + $body = curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($body === false || $code !== 207) { + return null; + } + + // Nextcloud returns getcontentlength in the PROPFIND response. + if (preg_match('/]*>(\d+)<\/d:getcontentlength>/i', $body, $m)) { + return (int) $m[1]; + } + // Fallback: any numeric "getcontentlength" (namespace may differ). + if (preg_match('/getcontentlength[^>]*>(\d+) 'PROPFIND', + CURLOPT_USERPWD => $user . ':' . $pass, + CURLOPT_HTTPAUTH => CURLAUTH_BASIC, + CURLOPT_HTTPHEADER => ['Depth: 1'], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + ]); + + $body = curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($body === false || $code !== 207) { + error_log("[nextcloud-sync] prune: PROPFIND failed (HTTP {$code})"); + return false; + } + + // Extract hrefs of our snapshot files (names starting with REMOTE_PREFIX). + $names = []; + if (preg_match_all('/([^<]+)<\/d:href>/i', $body, $m)) { + foreach ($m[1] as $href) { + $decoded = rawurldecode(basename(rtrim($href, '/'))); + if (str_starts_with($decoded, REMOTE_PREFIX) && str_ends_with($decoded, '.db.gz')) { + $names[] = $decoded; + } + } + } + + $names = array_values(array_unique($names)); + sort($names); // ascending; oldest last become deletable + + $excess = count($names) - $keep; + if ($excess <= 0) { + return true; + } + + $toDelete = array_slice($names, 0, $excess); // oldest first + foreach ($toDelete as $name) { + $url = rtrim(WEBDAV_BASE, '/') . '/' . rawurlencode($name); + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_CUSTOMREQUEST => 'DELETE', + CURLOPT_USERPWD => $user . ':' . $pass, + CURLOPT_HTTPAUTH => CURLAUTH_BASIC, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + ]); + curl_exec($ch); + $delCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($delCode === 204 || $delCode === 404) { + echo "[nextcloud-sync] Pruned remote {$name}\n"; + } else { + error_log("[nextcloud-sync] prune: DELETE {$name} failed (HTTP {$delCode})"); + } + } + + return true; +}