From a9cccc65505401461c178e5421c8230b846c599a Mon Sep 17 00:00:00 2001 From: Zareian Date: Sat, 10 Oct 2026 16:53:10 +0330 Subject: [PATCH] Live off-site Postgres copy on 247 replaces the nightly pg_dump; backup retention in deploy.sh - ops/pgbackup: SSH tunnel + pg_receivewal (slot zareian_247) + daily verified pg_basebackup (keep 7) and a restore check, all run on 78.38.35.247 as systemd units - /api/health reports offsiteBackup (slot active/lag/lost) and warns; OFFSITE_WAL_SLOT setting - removed statuslog backup_db/backup_verify and their health warnings - deploy.sh keeps 3 pre-deploy dumps, 3 local patches, drops manual backups after 30 days - docs: operations backup section, security item 14 closed, CLAUDE.md status Co-Authored-By: Claude Opus 5.5 --- .env.example | 3 + CLAUDE.md | 31 ++- README.md | 5 - config/health.py | 88 ++++--- config/settings.py | 10 +- config/tests_offsite_backup.py | 40 ++++ deploy.sh | 28 ++- docs/operations.md | 68 +++++- docs/security-before-production.md | 15 +- ops/pgbackup/basebackup.sh | 28 +++ ops/pgbackup/restore-check.sh | 51 ++++ ops/pgbackup/zareian-pg-basebackup.service | 10 + ops/pgbackup/zareian-pg-basebackup.timer | 9 + ops/pgbackup/zareian-pg-tunnel.service | 19 ++ ops/pgbackup/zareian-pg-wal.service | 19 ++ payment/tests_audit_log.py | 26 ++ statuslog/backup.py | 224 ------------------ statuslog/management/__init__.py | 0 statuslog/management/commands/__init__.py | 0 statuslog/management/commands/backup_db.py | 16 -- .../management/commands/backup_verify.py | 17 -- statuslog/tests_backup.py | 114 --------- 22 files changed, 374 insertions(+), 447 deletions(-) create mode 100644 config/tests_offsite_backup.py create mode 100755 ops/pgbackup/basebackup.sh create mode 100755 ops/pgbackup/restore-check.sh create mode 100644 ops/pgbackup/zareian-pg-basebackup.service create mode 100644 ops/pgbackup/zareian-pg-basebackup.timer create mode 100644 ops/pgbackup/zareian-pg-tunnel.service create mode 100644 ops/pgbackup/zareian-pg-wal.service create mode 100644 payment/tests_audit_log.py delete mode 100644 statuslog/backup.py delete mode 100644 statuslog/management/__init__.py delete mode 100644 statuslog/management/commands/__init__.py delete mode 100644 statuslog/management/commands/backup_db.py delete mode 100644 statuslog/management/commands/backup_verify.py delete mode 100644 statuslog/tests_backup.py diff --git a/.env.example b/.env.example index cc53c45..8296f5a 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,9 @@ DB_USER= DB_PASSWORD= DB_HOST=127.0.0.1 DB_PORT=5432 +# اسلاتِ تکثیری که سرورِ بکاپ از آن کپیِ زنده می‌گیرد (ops/pgbackup/). روی سرور: zareian_247. +# خالی روی Postgres = کپیِ بیرونی نداریم و /api/health هشدار می‌دهد. روی لپ‌تاپ خالی. +OFFSITE_WAL_SLOT= # ═══════════════════════════════════════════════════════════════════════ # ۲) دامنه‌ها — دو آدرس، بقیه از آن‌ها ساخته می‌شوند diff --git a/CLAUDE.md b/CLAUDE.md index c393468..1818450 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -328,7 +328,7 @@ reliably pull the final answer out of a solution and produced ~40% broken output ## Known gaps (per README pre-deploy checklist) -- Postgres is live; no off-machine backup yet (owner waiting for the destination from the senior). +- Postgres is live and streamed live to the backup server (see «بکاپ» below); restoring onto 249 itself never rehearsed. - Throttle rates need periodic review against real traffic. - `DEV_MASTER_OTP` (`0000`) must stay gated behind `DEBUG` — never enabled in prod. - No video content exists yet, though `Lesson.kind`/`video_url` can express it. @@ -445,6 +445,29 @@ preferred setup, because there is then no key to leak. **Never let the server ru `django-insecure-…` fallback from `config/settings.py`: that string is committed here, so anyone who can read this repo can forge a token for any user.** +## بکاپ: کپیِ زنده روی سرورِ ۲۴۷ — از ۲۰۲۶-۱۰-۱۰ + +**هر تغییرِ دیتابیس لحظه‌ای به سرورِ دیگری می‌رود؛ بکاپِ شبانه‌ی خودمان (`backup_db`/`backup_verify`/cron) حذف شد.** +شرحِ کامل و رویه‌ی بازیابی: `docs/operations.md` «بکاپ و بازیابی». فایل‌ها: `ops/pgbackup/`. + +- **سرورِ بکاپ:** `administrator@78.38.35.247`، همان رمزِ ۲۴۹ (همان فایلِ رمز). شبکه‌ی داخلی: ۲۴۹ = `192.168.200.164`، + ۲۴۷ = `192.168.200.167`. **۲۴۷ هم ماشینِ مشترک است** (artina، judge0، metaverse، docker، و `postgresql@14-main`ِ + دیگران روی ۵۴۳۲): همان قواعدِ «The server» این‌جا هم هست. مالِ ما فقط `~/zareian-pgbackup/` و یونیت‌های + `zareian-pg-*` است. Postgresِ آن‌جا را به کار نمی‌گیریم؛ فقط ابزارهای Postgres 14 را اجرا می‌کنیم. +- **چطور:** روی ۲۴۷ `zareian-pg-tunnel` (SSH با کلیدِ `~/.ssh/zareian_pgbackup` که در `authorized_keys`ِ ۲۴۹ به + `permitopen="127.0.0.1:5440"` محدود است، شِل نمی‌دهد) → `zareian-pg-wal` (`pg_receivewal`، اسلاتِ `zareian_247`، + نقشِ `zareian_replicator`، رمزش فقط در `~/.pgpass`ِ ۲۴۷) → `wal/`؛ و `zareian-pg-basebackup.timer` هر روز ۰۴:۳۰ → + `base/` (۷ تا، با `pg_verifybackup`؛ WALِ قدیمی‌تر از قدیمی‌ترین پاک می‌شود). +- **۲۴۹ را نمی‌خواباند:** تکثیر ناهمزمان است (`sync_state: async`)؛ `max_slot_wal_keep_size=1GB` (با `ALTER SYSTEM`، + نه `/etc`) سقفِ چیزی است که در قطعیِ ۲۴۷ روی دیسکِ ۲۴۹ جمع می‌شود. قطعیِ طولانی‌تر اسلات را `lost` می‌کند (رویه در docs). +- **پایش:** `OFFSITE_WAL_SLOT=zareian_247` در `.env`ِ ۲۴۹؛ `/api/health` (با کلید) `offsiteBackup` و هشدار می‌دهد + (`config/tests_offsite_backup.py`). شکستِ نسخه‌ی روزانه فقط در `journalctl -u zareian-pg-basebackup`ِ ۲۴۷ دیده می‌شود. +- **سنجیده (۲۰۲۶-۱۰-۱۰):** کلید شِل نمی‌گیرد و تونل به پورتِ دیگر رد می‌شود؛ `restore-check.sh` روی ۲۴۷ شمارش و آخرین + زمانِ کاربر/ورود/سفارش/تراکنش/تغییرِ وضعیت را برابرِ ۲۴۹ داد؛ یک ردیفِ آزمایشی که بعد از نسخه‌ی کامل روی ۲۴۹ نوشته شد + ۲ ثانیه بعد در بازیابیِ ۲۴۷ بود (جدولش پاک شد). **نسنجیده:** ری‌بوتِ ۲۴۷، قطعیِ طولانی تا `lost`، اجرای خودکارِ timer + (اولین بار ۲۰۲۶-۱۰-۱۱ ۰۴:۳۰)، و برگرداندنِ بازیابی روی خودِ ۲۴۹. +- ایمیجِ روزانه‌ی کلِ ماشین کارِ ادمینِ سرور است؛ محتوایش را ما نسنجیده‌ایم. کپیِ ما به آن تکیه نمی‌کند. + ## اشتراک و دیوارِ پرداخت — روی سرور از ۲۰۲۶-۱۰-۱۰ ۰۱:۱۹ (بک‌اند `64c4c09`، فرانت `a8a5351`) - **فقط فصلِ اولِ هر پایه رایگان است** (`content.views.unit_is_free`). بیرون از آن، بی‌اشتراکِ فعال، سوال‌های درس، @@ -903,9 +926,9 @@ migrationها اعمال شدند، سه‌یار و `nextLessonId` از بیر (`templates/tadrisino/`) تا متنِ فارسی راست‌به‌چپ رندر شود. قالبِ drf-spectacular دست‌نخورده است و با `{{ block.super }}` گسترش داده شده. -**بکاپ:** `deploy.sh` پیش از `migrate` خودش بکاپ می‌گیرد (`db-before-deploy-*`، ۲ تای آخر، -بی‌بکاپ migrate نمی‌شود)؛ روی سرور هم اجرا شد (۲۰۲۶-۱۰-۰۷، `integrity_check: ok`). بیرون از ماشین بکاپی -نیست. +**بکاپ:** `deploy.sh` پیش از `migrate` خودش بکاپ می‌گیرد (`db-before-deploy-*`، ۳ تای آخر، بی‌بکاپ +migrate نمی‌شود) و مرحله‌ی «نگه‌داریِ بکاپ‌ها» بقیه‌ی `.deploy-backups` را مرتب می‌کند (بکاپِ دستیِ پیش از +تغییر بعد از ۳۰ روز پاک می‌شود). کپیِ واقعیِ داده بیرون از ماشین است؛ بخشِ پایین. **⚠️ Gitea از روی سرور:** از ۲۰۲۶-۱۰-۰۷ `https://gitea.tadrisino2.ir` از خودِ سرور (حل‌شده به IPهای ArvanCloud) ۵۰۴ می‌دهد و از لپ‌تاپ ۲۰۰؛ علتش پیدا نشد (مسیرِ آروان↔سرور). `deploy.sh` در `git fetch` می‌ایستد. diff --git a/README.md b/README.md index 32614cd..d4d65fd 100644 --- a/README.md +++ b/README.md @@ -134,11 +134,6 @@ curl https://mathapi.tadrisino2.ir/api/health اینها عمداً نوشته شده‌اند تا کسی دوباره کشفشان نکند: -- **دیتابیس هنوز SQLite است**، نه Postgres. برای حجمِ فعلی کافی‌ست، ولی - تک‌نویسنده بودنش اولین گلوگاهِ مقیاس است. -- **بکاپ فقط روی همان ماشین است.** `deploy.sh` پیش از `migrate` از `db.sqlite3` بکاپ می‌گیرد - (۲ تای آخر)، ولی نسخه‌ای بیرون از سرور نیست. جزئیات در - [`docs/operations.md`](docs/operations.md). - **`CACHE_URL` روی سرور خالی است، عمداً**، پس محدودیتِ نرخ بینِ workerها مشترک نیست. Redisِ روی آن ماشین مالِ تیم‌های دیگر است و به آن وصل نمی‌شویم؛ Redisِ اختصاصیِ خودمان یک تصمیمِ باز پیش از لانچ است. `/api/health` این را هشدار می‌دهد. diff --git a/config/health.py b/config/health.py index 8030cbc..2202684 100644 --- a/config/health.py +++ b/config/health.py @@ -144,41 +144,62 @@ def _ai_provider_state() -> dict: return {'state': row['new_status'], 'since': row['created_at'].isoformat()} -def _backup_warnings() -> list[str]: - """بکاپِ روزانه، کپیِ بیرونی و آزمونِ بازیابی؛ فقط وقتی Postgres است (SQLite یعنی توسعه‌ی محلی).""" - from datetime import datetime, timezone as dt_tz +def _offsite_backup() -> dict: + """ + کپیِ زنده‌ی دیتابیس روی سرورِ دیگر (`ops/pgbackup/`، شرح در docs/operations.md «بکاپ»). - from statuslog.backup import read_status + آن سرور با `pg_receivewal` از اسلاتِ تکثیرِ `OFFSITE_WAL_SLOT` هر تغییر را لحظه‌ای می‌گیرد؛ این‌جا از + سمتِ همین دیتابیس دیده می‌شود که وصل است و چقدر عقب است. ست‌نبودنِ اسلات روی Postgres یعنی کپیِ + بیرونی نداریم و هشدار می‌شود. base backupِ روزانه‌ی آن سرور از این‌جا دیده نمی‌شود (journalِ همان‌جا). + """ + if connection.vendor != 'postgresql': + return {'state': 'not_postgres'} + slot = settings.OFFSITE_WAL_SLOT + if not slot: + return {'state': 'not_configured'} + try: + with connection.cursor() as cursor: + cursor.execute( + 'SELECT active, wal_status, pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) ' + 'FROM pg_replication_slots WHERE slot_name = %s', + [slot], + ) + row = cursor.fetchone() + except Exception as exc: # noqa: BLE001 + return {'state': 'error', 'error': type(exc).__name__} + return _slot_state(row) - if settings.DEBUG or connection.vendor != 'postgresql': - return [] - st = read_status() - now = datetime.now(dt_tz.utc) - def age_hours(iso: str): - return (now - datetime.fromisoformat(iso)).total_seconds() / 3600 if iso else None +def _slot_state(row) -> dict: + if row is None: + return {'state': 'slot_missing'} + active, wal_status, lag = row + lag = int(lag or 0) + if wal_status == 'lost': + # سقفِ max_slot_wal_keep_size خورده: دیسکِ این سرور امن ماند ولی زنجیره‌ی WAL آن‌جا بریده است. + state = 'lost' + elif not active: + state = 'disconnected' + elif lag > settings.OFFSITE_WAL_MAX_LAG_BYTES: + state = 'lagging' + else: + state = 'ok' + return {'state': state, 'lagBytes': lag} - out = [] - if st.get('corrupt'): - return ['status.json بکاپ خوانده نشد؛ لاگ را ببینید.'] - age = age_hours(st.get('last_ok_at')) - if age is None: - out.append('هیچ بکاپِ زمان‌بندی‌شده‌ای ثبت نشده (manage.py backup_db با cron اجرا نمی‌شود).') - elif age > settings.BACKUP_MAX_AGE_HOURS: - out.append(f'آخرین بکاپِ موفق {age:.0f} ساعت پیش بود (سقف {settings.BACKUP_MAX_AGE_HOURS}).') - if st.get('last_error'): - out.append(f"آخرین تلاشِ بکاپ شکست خورد: {st['last_error'][:200]}") - if (st.get('offsite') or {}).get('state') != 'ok': - out.append('کپیِ بکاپ بیرون از این ماشین نیست یا شکست خورده (BACKUP_OFFSITE_CMD).') - rc = st.get('restore_check') or {} - rc_age = age_hours(rc.get('at')) - if rc_age is None: - out.append('بازیابیِ بکاپ هنوز آزموده نشده (manage.py backup_verify).') - elif not rc.get('ok'): - out.append('آخرین آزمونِ بازیابی ناهمخوان بود.') - elif rc_age > settings.RESTORE_CHECK_MAX_AGE_DAYS * 24: - out.append(f'آخرین آزمونِ بازیابی {rc_age / 24:.0f} روز پیش بود.') - return out + +_OFFSITE_MESSAGES = { + 'not_configured': 'کپیِ زنده‌ی دیتابیس بیرون از این سرور تنظیم نشده (OFFSITE_WAL_SLOT).', + 'slot_missing': 'اسلاتِ تکثیرِ OFFSITE_WAL_SLOT در Postgres نیست؛ سرورِ بکاپ هیچ‌وقت وصل نشده یا اسلات حذف شده.', + 'disconnected': 'سرورِ بکاپ الان وصل نیست؛ تغییرات روی همین سرور جمع می‌شوند تا سقفِ max_slot_wal_keep_size.', + 'lagging': 'سرورِ بکاپ از دیتابیس عقب است.', + 'lost': 'سرورِ بکاپ آن‌قدر قطع بود که زنجیره‌ی WAL برید؛ روی آن سرور اسلات و base backupِ تازه لازم است.', + 'error': 'وضعیتِ کپیِ زنده‌ی دیتابیس خوانده نشد.', +} + + +def _offsite_warnings(offsite: dict) -> list[str]: + msg = _OFFSITE_MESSAGES.get(offsite['state']) + return [msg] if msg else [] def _warnings() -> list[str]: @@ -219,7 +240,6 @@ def _warnings() -> list[str]: 'اعتبارِ سرویسِ هوش مصنوعی (GapGPT) تمام شده: دستیار برای همه‌ی کاربرها ' '«در دسترس نیست» می‌دهد تا حساب شارژ شود.' ) - out.extend(_backup_warnings()) backend = settings.CACHES['default']['BACKEND'] if not settings.DEBUG and 'LocMem' in backend: out.append('کش محلیِ هر پروسه است؛ محدودیتِ نرخ بینِ workerها مشترک نیست.') @@ -261,7 +281,8 @@ class HealthView(APIView): if not HasInternalServiceKey().has_permission(request, self): return Response({'status': 'ok' if critical_ok else 'error'}, status=status) - warnings = _warnings() + offsite = _offsite_backup() + warnings = _warnings() + _offsite_warnings(offsite) payload = { 'status': 'ok' if critical_ok else 'error', 'debug': bool(settings.DEBUG), @@ -269,6 +290,7 @@ class HealthView(APIView): 'checks': checks, 'integrations': _check_integrations(), 'traffic': _traffic(), + 'offsiteBackup': offsite, 'allowedOrigins': list(getattr(settings, 'CORS_ALLOWED_ORIGINS', [])), # IPای که throttle برای همین درخواست می‌بیند. باید IPِ عمومیِ خودِ # صدازننده باشد؛ اگر IPِ ArvanCloud یا nginx بود، NUM_PROXIES غلط diff --git a/config/settings.py b/config/settings.py index adc0042..2e48b2b 100644 --- a/config/settings.py +++ b/config/settings.py @@ -970,11 +970,11 @@ LOGGING = { # Promtail بتونه tail کنه. بدونش (اجرای عادی خارج از Docker)، فقط stdout. LOG_DIR = os.environ.get('LOG_DIR', '') -# بکاپِ زمان‌بندی‌شده (statuslog/backup.py). پیش‌فرض داخلِ همان پوشه‌ی gitignoreشده‌ی deploy.sh است؛ -# مقصدِ بیرونِ ماشین جداست: BACKUP_OFFSITE_CMD در .env (ست‌نبودنش را /api/health هشدار می‌دهد). -BACKUP_DIR = os.environ.get('BACKUP_DIR', str(BASE_DIR / '.deploy-backups' / 'scheduled')) -BACKUP_MAX_AGE_HOURS = 26 # روزانه + دو ساعت جا؛ دیرتر از این یعنی cron نخورده -RESTORE_CHECK_MAX_AGE_DAYS = 35 +# کپیِ زنده‌ی دیتابیس روی سرورِ دیگر (ops/pgbackup/): نامِ اسلاتِ تکثیری که آن سرور از آن می‌خواند. +# خالی روی Postgres یعنی کپیِ بیرونی نداریم و /api/health هشدار می‌دهد. +OFFSITE_WAL_SLOT = os.environ.get('OFFSITE_WAL_SLOT', '') +# بیش از این عقب‌ماندن هشدار است. ۶۴ مگ ≈ چهار فایلِ WAL؛ در ترافیکِ عادی عقب‌ماندگی نزدیکِ صفر است. +OFFSITE_WAL_MAX_LAG_BYTES = 64 * 2**20 if LOG_DIR: os.makedirs(LOG_DIR, exist_ok=True) diff --git a/config/tests_offsite_backup.py b/config/tests_offsite_backup.py new file mode 100644 index 0000000..4fca963 --- /dev/null +++ b/config/tests_offsite_backup.py @@ -0,0 +1,40 @@ +""" +وضعیتِ کپیِ زنده‌ی دیتابیس در /api/health (config/health.py: _slot_state، _offsite_warnings). +پرسشِ واقعی از pg_replication_slots این‌جا اجرا نمی‌شود (تست‌ها روی SQLite‌اند)؛ روی سرور با اسلاتِ +واقعی دیده شد (docs/operations.md «بکاپ»). +""" +from django.test import SimpleTestCase, override_settings + +from config import health + + +class SlotStateTests(SimpleTestCase): + def test_missing_slot(self): + self.assertEqual(health._slot_state(None)['state'], 'slot_missing') + + def test_streaming_and_caught_up_is_ok(self): + self.assertEqual(health._slot_state((True, 'reserved', 0)), {'state': 'ok', 'lagBytes': 0}) + + def test_inactive_slot_is_disconnected(self): + self.assertEqual(health._slot_state((False, 'reserved', 0))['state'], 'disconnected') + + def test_lost_slot_wins_over_everything(self): + self.assertEqual(health._slot_state((False, 'lost', None))['state'], 'lost') + + @override_settings(OFFSITE_WAL_MAX_LAG_BYTES=100) + def test_lag_above_limit(self): + self.assertEqual(health._slot_state((True, 'reserved', 101))['state'], 'lagging') + self.assertEqual(health._slot_state((True, 'reserved', 100))['state'], 'ok') + + +class OffsiteWarningTests(SimpleTestCase): + def test_ok_and_dev_are_silent(self): + self.assertEqual(health._offsite_warnings({'state': 'ok', 'lagBytes': 0}), []) + self.assertEqual(health._offsite_warnings({'state': 'not_postgres'}), []) + + def test_every_bad_state_warns(self): + for state in ('not_configured', 'slot_missing', 'disconnected', 'lagging', 'lost', 'error'): + self.assertEqual(len(health._offsite_warnings({'state': state})), 1, state) + + def test_sqlite_reports_not_postgres(self): + self.assertEqual(health._offsite_backup(), {'state': 'not_postgres'}) diff --git a/deploy.sh b/deploy.sh index 24c9453..cd5d811 100755 --- a/deploy.sh +++ b/deploy.sh @@ -222,9 +222,10 @@ printf ' دیتابیس و بکاپ‌ها فقط برای %s خواندنی‌ # # با sqlite3.Connection.backup() نه cp: کپیِ ساده وسطِ یک نوشتن فایلِ نیمه‌کاره می‌دهد (sqlite3 # به‌عنوانِ فرمان روی سرور نیست). اگر بکاپ نشد، migrate اجرا نمی‌شود: دیپلوی بی‌بکاپ را -# عمداً نمی‌پذیریم. فقط ۲ بکاپِ خودکارِ آخر (db-before-deploy-*: همین انتشار و یکی قبل‌تر) نگه داشته می‌شود تا دیسکِ +# عمداً نمی‌پذیریم. فقط ۳ بکاپِ خودکارِ آخر (db-before-deploy-*: همین انتشار و دو تای قبل) نگه داشته می‌شود تا دیسکِ # مشترک پر نشود؛ بکاپ‌های دستی با نامِ دیگر دست نمی‌خورند. -# Postgres با pg_dump (آزموده‌نشده روی سرور تا انتقال)؛ بکاپ از بیرونِ ماشین که هنوز نیست (بند ۱۷ در چک‌لیستِ لانچ). +# Postgres با pg_dump. این بکاپ فقط برای برگشتن از migrationِ خراب است؛ کپیِ زنده‌ی بیرونِ ماشین جداست +# (ops/pgbackup/، docs/operations.md «بکاپ و بازیابی»). step "بکاپِ دیتابیس" python - <<'PYBACKUP' || fail "بکاپِ دیتابیس نشد؛ migrate اجرا نشد و هیچ تغییری روی داده اعمال نشد." import datetime @@ -252,7 +253,7 @@ if db['ENGINE'] == 'django.db.backends.postgresql': # فقط خوانا بودنِ فایل را می‌سنجد؛ بازیابیِ واقعی روی دیتابیسِ دیگر آزموده نشده. subprocess.run(['pg_restore', '--list', dst], check=True, stdout=subprocess.DEVNULL) print(f' {dst} ({os.path.getsize(dst) // 2**20} MB، pg_restore --list: ok)') - old = sorted(glob.glob('.deploy-backups/db-before-deploy-*.pgdump'))[:-2] + old = sorted(glob.glob('.deploy-backups/db-before-deploy-*.pgdump'))[:-3] for path in old: os.remove(path) raise SystemExit(0) @@ -285,13 +286,30 @@ if result != 'ok': raise SystemExit(f' بکاپ ساخته شد ولی integrity_check «{result}» داد: {dst}') print(f' {dst} ({os.path.getsize(dst) // 2**20} MB، integrity_check: ok)') -old = sorted(glob.glob('.deploy-backups/db-before-deploy-*.sqlite3'))[:-2] +old = sorted(glob.glob('.deploy-backups/db-before-deploy-*.sqlite3'))[:-3] for path in old: os.remove(path) if old: - print(f' {len(old)} بکاپِ خودکارِ قدیمی‌تر پاک شد (۲ تای آخر می‌ماند).') + print(f' {len(old)} بکاپِ خودکارِ قدیمی‌تر پاک شد (۳ تای آخر می‌ماند).') PYBACKUP +# ─── ۳.۸ نگه‌داریِ بکاپ‌ها ───────────────────────────────────────────────── +# .deploy-backups بی‌سقف بزرگ می‌شد و دیسکِ این ماشینِ مشترک ~۸۸٪ پُر است. قاعده‌ی هر انتشار: +# db-before-deploy-* ۳ تای آخر (مرحله‌ی قبل) +# local-changes/commits-* ۳ تای آخر از هر کدام +# هر چیزِ دیگر بکاپ‌های دستیِ پیش از تغییر (env-before-*، db-before-*، unit-*…): پس از ۳۰ روز +# پاک. کپیِ .env رمز دارد و یک ماه بعد کسی به آن برنمی‌گردد. +# کپیِ واقعیِ داده این‌جا نیست: کپیِ زنده روی سرورِ دیگر است (ops/pgbackup/). +step "نگه‌داریِ بکاپ‌ها" +if [ -d .deploy-backups ]; then + for pattern in 'local-changes-*.patch' 'local-commits-*.patch'; do + ls -1t .deploy-backups/$pattern 2>/dev/null | tail -n +4 | xargs -r rm -f -- + done + find .deploy-backups -maxdepth 1 -type f ! -name 'db-before-deploy-*' ! -name 'local-changes-*' \ + ! -name 'local-commits-*' -mtime +30 -print -delete | sed 's/^/ پاک شد: /' + printf ' .deploy-backups: %s\n' "$(du -sh .deploy-backups | cut -f1)" +fi + # ─── ۴. دیتابیس ──────────────────────────────────────────────────────────── step "اعمالِ مهاجرت‌های دیتابیس" python manage.py migrate --noinput || fail "migrate شکست خورد؛ سرویس ری‌استارت نشد." diff --git a/docs/operations.md b/docs/operations.md index faf8888..cb5c7cf 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -72,25 +72,73 @@ curl -i https://mathapi.tadrisino2.ir/api/health ## بکاپ و بازیابی -دیتابیسِ سرور Postgres است (cluster جدای `zareian`، پورت ۵۴۴۰، فقط localhost؛ راهنمای کامل: `گزارش-۲۹/راهنمای-Postgres-و-Redis.html`). سه لایه: +دیتابیسِ سرور Postgres است (cluster جدای `zareian`، پورت ۵۴۴۰، فقط localhost؛ راهنمای کامل: `گزارش-۲۹/راهنمای-Postgres-و-Redis.html`). -| لایه | چه زمانی | نگه‌داری | کجا | +| لایه | چه چیزی | از دست رفتنِ داده در بدترین حالت | کجا | |---|---|---|---| -| پیش از migrate | هر `./deploy.sh` (`pg_dump -Fc`؛ اگر نشد migrate اجرا نمی‌شود) | ۲ تای آخر | `.deploy-backups/db-before-deploy-*.pgdump` | -| روزانه | cron: `python manage.py backup_db` (`statuslog/backup.py`) | ۷ روزِ آخر + ۴ هفته‌ی آخر | `.deploy-backups/scheduled/` + `status.json` | -| بیرونِ ماشین | همان فرمان، اگر `BACKUP_OFFSITE_CMD` در `.env` باشد (فرمانی با `{file}`، مثلاً rsync/scp/rclone) | دست‌ِ مقصد | مقصد را ادمین تعیین می‌کند؛ تا تعیین نشده `/api/health` هشدار می‌دهد | +| کپیِ زنده (اصلی) | هر تغییرِ دیتابیس لحظه‌ای به سرورِ دیگر می‌رود + یک نسخه‌ی کامل در روز؛ بازیابی تا هر لحظه از ۷ روزِ اخیر | چند ثانیه‌ی آخر | `administrator@78.38.35.247:~/zareian-pgbackup/` | +| پیش از migrate | `./deploy.sh` (`pg_dump -Fc`؛ اگر نشد migrate اجرا نمی‌شود)؛ برای برگشتن از migrationِ خراب | — | `~/mathapp-v2/.deploy-backups/db-before-deploy-*.pgdump` (۲ تای آخر) | +| ایمیجِ کلِ ماشین | کارِ ادمینِ سرور، روزانه؛ ما محتوایش را نسنجیده‌ایم | — | دستِ ادمین | -نصبِ بکاپِ شبانه (یک بار، با `crontab -e` حسابِ `administrator`؛ بکاپِ crontab را پیش از ویرایش بگیرید): +بکاپِ شبانه‌ی جداگانه (`backup_db` با cron) حذف شد: کپیِ زنده همان کار را بهتر می‌کند و ایمیجِ روزانه را ادمین دارد. + +### کپیِ زنده چطور کار می‌کند + +Postgres هر تغییر را اول در یک دفترِ پیوسته (WAL) می‌نویسد. سرورِ ۲۴۷ این دفتر را با ابزارِ خودِ Postgres +(`pg_receivewal`) لحظه‌ای می‌گیرد، نه کپیِ کلِ دیتابیس: فقط تغییرات می‌روند. روزی یک بار هم یک نسخه‌ی کامل +(`pg_basebackup`) گرفته می‌شود؛ ۷ نسخه‌ی آخر می‌مانند و WALِ قدیمی‌تر از قدیمی‌ترینشان پاک می‌شود، پس حجم +محدود است. بازیابی = آخرین نسخه‌ی کاملِ پیش از لحظه‌ی دلخواه + بازپخشِ WAL تا آن لحظه. ``` -15 2 * * * cd /home/administrator/mathapp-v2 && venv/bin/python manage.py backup_db >> .deploy-backups/scheduled/cron.log 2>&1 +۲۴۹ (اپ) ۲۴۷ (بکاپ) +Postgres 5440 (localhost) ◄── تونلِ SSH ─── zareian-pg-tunnel (127.0.0.1:15440) + اسلاتِ zareian_247 zareian-pg-wal → wal/ (لحظه‌ای) + نقشِ zareian_replicator zareian-pg-basebackup.timer → base/ (هر روز ۰۴:۳۰، ۷ تا) ``` -**آزمونِ بازیابی (ماهی یک بار، و پس از هر تغییرِ بکاپ):** `venv/bin/python manage.py backup_verify` آخرین بکاپ را روی دیتابیسِ آزمایشیِ `_restorecheck` برمی‌گرداند، شمارشِ کاربر/ورود/سفارش/تراکنش/لاگِ وضعیت را با لحظه‌ی بکاپ می‌سنجد و آن دیتابیس را می‌اندازد؛ به دیتابیسِ اصلی دست نمی‌زند. نقشِ برنامه باید `CREATEDB` داشته باشد (`ALTER ROLE zareian_app CREATEDB;` با ادمینِ Postgres). فایلِ دست‌خورده را با هش رد می‌کند. +- **۲۴۹ را نمی‌خواباند:** اپ منتظرِ ۲۴۷ نمی‌ماند (تکثیرِ ناهمزمان). اگر ۲۴۷ قطع شود، اسلات تغییرات را نگه می‌دارد تا + وصل شود، ولی حداکثر ۱ گیگ (`max_slot_wal_keep_size`)؛ بیش از آن اسلات «lost» می‌شود و دیسکِ ۲۴۹ امن می‌ماند. + نسخه‌ی کامل با `--checkpoint=spread --max-rate=20M` گرفته می‌شود. +- **امنیت:** کلیدِ SSHِ ۲۴۷ در `authorized_keys`ِ ۲۴۹ با `restrict,port-forwarding,permitopen="127.0.0.1:5440"` + محدود است (نه شِل، نه پورتِ دیگر). رمزِ نقشِ `zareian_replicator` فقط در `~/.pgpass`ِ ۲۴۷ است. این نقش + همه‌ی داده را می‌خواند (طبیعتِ بکاپ)، پس `~/zareian-pgbackup` با مجوز ۷۰۰ است. ترافیک در شبکه‌ی داخلی است + (۲۴۹ = `192.168.200.164`، ۲۴۷ = `192.168.200.167`). +- **Postgresِ ۲۴۷ را به کار نمی‌گیریم:** سرویسِ `postgresql@14-main` آن‌جا مالِ دیگران است. ما فقط ابزارهای + Postgres 14 را اجرا می‌کنیم؛ آزمونِ بازیابی یک Postgresِ موقت روی سوکتِ داخلِ پوشه‌ی خودمان بالا می‌آورد. +- **پایش:** `/api/health` (با `X-Internal-Key`) زیرِ `offsiteBackup` وضعیت و عقب‌ماندگی را می‌دهد و اگر ۲۴۷ + وصل نباشد، عقب باشد یا اسلات lost شده باشد هشدار می‌دهد (`OFFSITE_WAL_SLOT=zareian_247` در `.env`ِ ۲۴۹). + شکستِ نسخه‌ی کاملِ روزانه از ۲۴۹ دیده نمی‌شود: روی ۲۴۷ `systemctl status zareian-pg-basebackup` و + `journalctl -u zareian-pg-basebackup`. شکستش داده را از دست نمی‌دهد (WAL از آخرین نسخه‌ی سالم نگه داشته + می‌شود)، فقط بازیابی را کندتر می‌کند. -**بازیابیِ واقعی** (دیتابیسِ اصلی خراب شد): `sudo systemctl stop zareian-mathapp` ← از دیتابیسِ فعلی یک `pg_dump` دیگر بگیرید ← `dropdb`/`createdb` ← `pg_restore --no-owner -d zareian_mathapp <فایل>` ← `start` ← `/api/health`. سفارش‌های بعد از زمانِ بکاپ را با پنلِ زرین‌پال/بازار تطبیق دهید. +فایل‌ها در `ops/pgbackup/` ریپو. روی ۲۴۷: یونیت‌ها در `/etc/systemd/system/zareian-pg-*`، اسکریپت‌ها در +`~/zareian-pgbackup/bin/`. هر دو با دست کپی می‌شوند (`deploy.sh` فقط ۲۴۹ را می‌شناسد)؛ بعد از تغییرِ +یکی از آن‌ها: کپی، `sudo systemctl daemon-reload`، و `restart` همان یونیت. -آزموده‌شده: بکاپ، نگه‌داری، بازیابی و رد‌شدنِ فایلِ خراب روی Postgresِ لپ‌تاپ. آزموده‌نشده: زیر cron روی سرور، دیسکِ نزدیک به پُر، `BACKUP_OFFSITE_CMD` واقعی. +### آزمونِ بازیابی (ماهی یک بار) + +روی ۲۴۷: `~/zareian-pgbackup/bin/restore-check.sh` (یا با یک لحظه: `… '2026-10-10 16:30:00+03:30'`). +آخرین نسخه‌ی کامل + همه‌ی WAL را در پوشه‌ی موقت بالا می‌آورد، شمارش و آخرین زمانِ کاربر/ورود/سفارش/تراکنش/ +تغییرِ وضعیت را چاپ می‌کند و پاک می‌کند. همان پرسش را روی ۲۴۹ بزنید؛ باید یکی باشند (جز تغییرِ همان چند ثانیه). + +### اگر ۲۴۷ مدتی قطع بود + +`/api/health` می‌گوید `disconnected`. با برگشتنِ ۲۴۷ سرویس‌ها خودشان وصل می‌شوند و عقب‌ماندگی را می‌گیرند. اگر +`lost` شد (بیش از ۱ گیگ تغییر در قطعی): روی ۲۴۷ +`systemctl stop zareian-pg-wal`، روی ۲۴۹ اسلات را بیندازید +(`SELECT pg_drop_replication_slot('zareian_247')`)، روی ۲۴۷ بسازید +(`pg_receivewal -h 127.0.0.1 -p 15440 -U zareian_replicator --create-slot --slot=zareian_247`)، +`systemctl start zareian-pg-wal` و `systemctl start zareian-pg-basebackup` (نسخه‌ی کاملِ تازه؛ WALِ پیش از آن +بریده است). + +### بازیابیِ واقعی + +- **دیتابیسِ ۲۴۹ خراب شد یا داده‌ای اشتباهی پاک شد:** لحظه‌ی درست را پیدا کنید، روی ۲۴۷ با `restore-check.sh` + تا آن لحظه آزمایش کنید؛ وقتی درست بود، با `KEEP=1` اجرا کنید تا Postgresِ موقت بماند، از آن با + `pg_dump -Fc -h <پوشه> -U postgres zareian_mathapp` خروجی بگیرید و روی ۲۴۹ با `pg_restore --clean --no-owner -d zareian_mathapp` برگردد (اپ را پیش از آن stop کنید و از + وضعیتِ فعلی هم یک `pg_dump` بگیرید). این مسیر روی سرور آزموده نشده؛ با کمک بروید. +- **کلِ ۲۴۹ رفت:** همان بازیابی تا انتهای WAL روی ماشینِ تازه، و سفارش‌های چند ثانیه‌ی آخر را با پنلِ زرین‌پال/بازار + تطبیق دهید. بکاپِ دستیِ SQLite (توسعه‌ی محلی): diff --git a/docs/security-before-production.md b/docs/security-before-production.md index 31d86fb..9b4c92b 100644 --- a/docs/security-before-production.md +++ b/docs/security-before-production.md @@ -28,7 +28,6 @@ | ۹ | کشِ مشترک بینِ workerها نیست (Redisِ اختصاصی)؛ سقف‌های مبتنی بر کش ≈ ۳ برابرند | متوسط | تصمیم + ساختِ Redis | | ۱۱ | کدِ OTP بی‌هش در دیتابیس و در فهرستِ پنل | کم | کد | | ۱۲ | هزینه‌ی دستیارِ AI با ساختِ شماره‌ی تازه (هر شماره ۲ سوالِ رایگان) قابلِ بالا بردن است | کم (مالی) | کد | -| ۱۴ | SQLite روی دیسکِ مشترکِ پُر؛ بکاپِ پیش از migrate خودکار است ولی بیرون از ماشین نیست | کم (داده) | زیرساخت | | ۱۵ | دسترسیِ سرور: حسابِ مشترک، رمز، sudoِ کامل | بالا | ادمینِ سرور | | ۱۶ | فرانت توکن را در `localStorage` نگه می‌دارد | کم | فرانت | | ۲۲ | سایتِ فرانت هیچ هدرِ امنیتی ندارد (CSP، frame-ancestors، HSTS) | کم | nginx/فرانت | @@ -116,19 +115,6 @@ با سقفِ بسیار کمتر برای مهمان؛ هشدار وقتی `aiProvider.state` در `/api/health` برابرِ `quota_exhausted` شد. -## ۱۴. دیتابیس و بکاپ - -- **چیست:** SQLite در `~/mathapp-v2/db.sqlite3`، روی دیسکی که بینِ چند سرویس مشترک است و - ~۸۳٪ پر است. `transaction_mode: IMMEDIATE` هم‌زمانی را درست ولی سریالی می‌کند. `deploy.sh` - پیش از `migrate` بکاپ می‌گیرد (`.deploy-backups/db-before-deploy-*`، ۲ تای آخر؛ اگر بکاپ - نشد، migrate اجرا نمی‌شود)، ولی همه در همان پوشه و روی همان دیسک‌اند. روی لپ‌تاپ و در دیپلوی ۲۰۲۶-۱۰-۰۷ روی سرور آزموده شد - (ساخت و `integrity_check`؛ حذفِ قدیمی‌ها فقط روی لپ‌تاپ). دیتابیسِ لپ‌تاپ ۱۲۶ مگابایت - است و دو بکاپ ~۲۵۰ مگابایت می‌گیرند؛ پیش از اولین دیپلو `df -h /` را ببینید. -- **خطر:** از دست رفتنِ کلِ داده با یک خطای دیسک یا یک دستورِ اشتباه؛ پُر شدنِ دیسک - سرویس‌های دیگر (Gitea، ایمیل) را هم می‌خواباند. -- **رفع:** مهاجرت به Postgres (روی همان ماشین نصب است؛ بعدش بکاپِ `deploy.sh` باید `pg_dump` شود، - الان فقط هشدار می‌دهد) و نگه‌داریِ یک نسخه بیرون از ماشین. - ## ۱۵. دسترسیِ سرور - **چیست:** ورود با رمز به حسابِ مشترکِ `administrator` با `sudo (ALL : ALL) ALL` و عضوِ @@ -235,6 +221,7 @@ | قلبِ صفر جوابِ سوال را می‌بندد؛ چکش داخلِ تراکنش است (بی‌جواب‌ماندن و جزیره‌ی قفل جداگانه در ردیفِ بعدی بسته شده‌اند) | `content/tests_hearts_gate.py` | | `select_for_update` مؤثر با `transaction_mode: IMMEDIATE`؛ مقایسه‌ی زمان‌ثابتِ `X-Internal-Key` | `payment/tests_audit_fixes.py` | | `deploy.sh` پیش از `migrate` بکاپِ `db-before-deploy-*` می‌گیرد، `integrity_check` می‌کند و اگر نشد migrate را اجرا نمی‌کند | مرحله‌ی «بکاپِ دیتابیس»: ۷ بار روی لپ‌تاپ و یک بار روی سرور (۶ مگابایت، `integrity_check: ok`) | +| دیتابیس Postgresِ اختصاصی است و هر تغییرش لحظه‌ای روی سرورِ دیگری (۲۴۷) کپی می‌شود، با نسخه‌ی کاملِ روزانه و بازیابی تا هر لحظه از ۷ روزِ اخیر؛ قطعیِ ۲۴۷ اپ را نمی‌خواباند و بیش از ۱ گیگ روی دیسکِ ۲۴۹ جمع نمی‌شود (`docs/operations.md` «بکاپ و بازیابی») | `config/tests_offsite_backup.py` برای هشدارِ `/api/health`؛ روی سرور: بازیابی روی ۲۴۷ با شمارش‌های برابرِ ۲۴۹ و یک ردیفِ آزمایشی که ۲ ثانیه بعد آن‌جا بود | | دیتابیس و بکاپ‌ها فقط برای صاحبِ سرویس خواندنی‌اند (مرحله‌ی «مجوزِ فایل‌های داده» در `deploy.sh`) | با `sudo -u nobody head -c 15 ~/mathapp-v2/db.sqlite3` روی سرور باید خطای دسترسی بدهد | | پانزده مسیرِ خواندنی و نوشتنیِ کم‌خطر سقفِ نرخ دارند (بانکِ سوال و intro بی‌ورود، `progress/nodes`، `user/hearts`، اعلان، تیکت، پلن‌ها، وضعیتِ اشتراک، سفارش، پیشنهادها، affiliate، تاریخچه و فیدبکِ دستیار، خروج). با نرخِ ۳ در دقیقه درخواستِ چهارم ۴۲۹ می‌گیرد | `content/tests_read_throttles.py` | | ساختنِ دستیِ `OTPCode` و `Session` در پنل فقط برای ابرکاربر (کارمندِ عادی ۴۰۳) | `accounts/tests_security_review.py` (`StaffCannotMintLoginsTests`) | diff --git a/ops/pgbackup/basebackup.sh b/ops/pgbackup/basebackup.sh new file mode 100755 index 0000000..58b460f --- /dev/null +++ b/ops/pgbackup/basebackup.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# نسخه‌ی کاملِ روزانه از Postgresِ ۲۴۹، روی ۲۴۷. هفت نسخه‌ی آخر می‌ماند و WALِ قدیمی‌تر از قدیمی‌ترینِ +# آن‌ها پاک می‌شود؛ پس بازیابی تا هر لحظه از هفت روزِ اخیر ممکن است و حجم محدود می‌ماند. +# --checkpoint=spread و --max-rate بار روی ۲۴۹ را کم نگه می‌دارند (آن‌جا سرورِ اصلی است). +# نسخه تا pg_verifybackup قبولش نکند جای قبلی‌ها را نمی‌گیرد و چیزی پاک نمی‌شود. +set -euo pipefail +ROOT=/home/administrator/zareian-pgbackup +BIN=/usr/lib/postgresql/14/bin +KEEP=7 + +cd "$ROOT" +umask 077 +ts=$(date +%Y%m%d-%H%M%S) +rm -rf base/.inprogress-* +tmp="base/.inprogress-$ts" + +"$BIN/pg_basebackup" -h 127.0.0.1 -p 15440 -U zareian_replicator --no-password \ + -D "$tmp" -Fp -X stream --checkpoint=spread --max-rate=20M +"$BIN/pg_verifybackup" "$tmp" +mv "$tmp" "base/$ts" + +ls -1d base/2* | head -n -"$KEEP" | xargs -r rm -rf +oldest=$(ls -1d base/2* | head -1) +start_wal=$(sed -n 's/^START WAL LOCATION: .* (file \([0-9A-F]*\))$/\1/p' "$oldest/backup_label") +[ -n "$start_wal" ] || { echo "START WAL در $oldest/backup_label پیدا نشد" >&2; exit 1; } +"$BIN/pg_archivecleanup" wal "$start_wal" + +echo "ok base/$ts ($(du -sh "base/$ts" | cut -f1)), kept $(ls -1d base/2* | wc -l), wal since $start_wal ($(du -sh wal | cut -f1))" diff --git a/ops/pgbackup/restore-check.sh b/ops/pgbackup/restore-check.sh new file mode 100755 index 0000000..2db122d --- /dev/null +++ b/ops/pgbackup/restore-check.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# آزمونِ بازیابی روی خودِ ۲۴۷: آخرین نسخه‌ی کامل + همه‌ی WALِ رسیده را در پوشه‌ی موقت بالا می‌آورد +# (Postgresِ موقت روی سوکتِ داخلِ همان پوشه، بی‌پورتِ شبکه؛ به Postgresِ دیگرانِ این ماشین دست نمی‌زند)، +# شمارشِ جدول‌های کاربر/ورود/پرداخت و آخرین زمان‌ها را چاپ می‌کند و همه‌چیز را پاک می‌کند. +# همان پرسش را روی ۲۴۹ بزنید و مقایسه کنید. با آرگومان، تا یک لحظه برمی‌گردد: +# restore-check.sh '2026-10-10 16:30:00+03:30' +# با KEEP=1 پوشه و Postgresِ موقت می‌مانند (برای بازیابیِ واقعی: از آن pg_dump بگیرید، بعد pg_ctl stop و rm). +set -euo pipefail +ROOT=/home/administrator/zareian-pgbackup +BIN=/usr/lib/postgresql/14/bin +TARGET="${1:-}" + +latest=$(ls -1d "$ROOT"/base/2* | tail -1) +work=$(mktemp -d "$ROOT/restore-check.XXXXXX") +if [ "${KEEP:-0}" != 1 ]; then + trap '"$BIN/pg_ctl" -D "$work/data" -m immediate stop >/dev/null 2>&1 || true; rm -rf "$work"' EXIT +fi + +cp -a "$latest" "$work/data" +mkdir "$work/wal" +cp "$ROOT"/wal/* "$work/wal/" +# فایلِ در حالِ نوشتن (.partial) هم داده‌ی واقعی است؛ برای بازیابی نامِ کامل می‌گیرد. +for f in "$work"/wal/*.partial; do [ -e "$f" ] && mv "$f" "${f%.partial}"; done + +# postgresql.conf و pg_hba.conf روی اوبونتو در /etc اند، نه در پوشه‌ی داده؛ برای نمونه‌ی موقت ساخته می‌شوند. +cat > "$work/data/postgresql.conf" < "$work/data/pg_hba.conf" +: > "$work/data/postgresql.auto.conf" +touch "$work/data/recovery.signal" + +"$BIN/pg_ctl" -D "$work/data" -l "$work/log" -w -t 600 start >/dev/null +for _ in $(seq 120); do + [ "$("$BIN/psql" -h "$work" -U postgres -d zareian_mathapp -Atc 'select pg_is_in_recovery()')" = f ] && break + sleep 1 +done + +echo "base: $(basename "$latest") target: ${TARGET:-end of WAL}" +"$BIN/psql" -h "$work" -U postgres -d zareian_mathapp -c " +select 'users' what, count(*)::text n, max(created_at)::text latest from accounts_user +union all select 'login_events', count(*)::text, max(created_at)::text from accounts_loginevent +union all select 'orders', count(*)::text, max(created_at)::text from payment_order +union all select 'transactions', count(*)::text, max(created_at)::text from payment_transaction +union all select 'status_changes', count(*)::text, max(created_at)::text from statuslog_statuschangelog;" +[ "${KEEP:-0}" = 1 ] && echo "kept: $work (psql -h $work -U postgres -d zareian_mathapp)" +exit 0 diff --git a/ops/pgbackup/zareian-pg-basebackup.service b/ops/pgbackup/zareian-pg-basebackup.service new file mode 100644 index 0000000..163a5db --- /dev/null +++ b/ops/pgbackup/zareian-pg-basebackup.service @@ -0,0 +1,10 @@ +# روی سرورِ بکاپ (۲۴۷). یک نسخه‌ی کاملِ روزانه؛ بازیابی = آخرین نسخه + WALِ بعدش تا هر لحظه. +[Unit] +Description=Zareian MathApp: daily base backup from 249 +Requires=zareian-pg-tunnel.service +After=zareian-pg-tunnel.service + +[Service] +Type=oneshot +User=administrator +ExecStart=/home/administrator/zareian-pgbackup/bin/basebackup.sh diff --git a/ops/pgbackup/zareian-pg-basebackup.timer b/ops/pgbackup/zareian-pg-basebackup.timer new file mode 100644 index 0000000..d6a854a --- /dev/null +++ b/ops/pgbackup/zareian-pg-basebackup.timer @@ -0,0 +1,9 @@ +[Unit] +Description=Zareian MathApp: daily base backup from 249 + +[Timer] +OnCalendar=*-*-* 04:30:00 +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/ops/pgbackup/zareian-pg-tunnel.service b/ops/pgbackup/zareian-pg-tunnel.service new file mode 100644 index 0000000..fd3c8e5 --- /dev/null +++ b/ops/pgbackup/zareian-pg-tunnel.service @@ -0,0 +1,19 @@ +# روی سرورِ بکاپ (۲۴۷). تونلِ SSH از 127.0.0.1:15440ِ این‌جا به Postgresِ ما روی ۲۴۹ (127.0.0.1:5440). +# کلید در authorized_keysِ ۲۴۹ با restrict,port-forwarding,permitopen="127.0.0.1:5440" محدود است: +# با آن نه شِل باز می‌شود نه پورتِ دیگری. ترافیک در شبکه‌ی داخلی (192.168.200.x) می‌ماند. +[Unit] +Description=Zareian MathApp: SSH tunnel to the app Postgres on 249 +After=network-online.target +Wants=network-online.target + +[Service] +User=administrator +ExecStart=/usr/bin/ssh -N -i /home/administrator/.ssh/zareian_pgbackup \ + -o BatchMode=yes -o ExitOnForwardFailure=yes \ + -o ServerAliveInterval=15 -o ServerAliveCountMax=3 \ + -L 127.0.0.1:15440:127.0.0.1:5440 administrator@192.168.200.164 +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target diff --git a/ops/pgbackup/zareian-pg-wal.service b/ops/pgbackup/zareian-pg-wal.service new file mode 100644 index 0000000..dc6145e --- /dev/null +++ b/ops/pgbackup/zareian-pg-wal.service @@ -0,0 +1,19 @@ +# روی سرورِ بکاپ (۲۴۷). هر تغییرِ دیتابیسِ ۲۴۹ را لحظه‌ای در wal/ می‌نویسد (pg_receivewal، ابزارِ خودِ Postgres). +# --synchronous: هر تکه همان لحظه روی دیسکِ این‌جا fsync می‌شود. اپ روی ۲۴۹ منتظرِ این‌جا نمی‌ماند +# (synchronous_standby_names خالی است)، پس قطعیِ ۲۴۷ هیچ پرداختی را کند یا متوقف نمی‌کند. +# اسلاتِ zareian_247 روی ۲۴۹ نگه می‌دارد که در قطعی چیزی جا نیفتد؛ سقفش max_slot_wal_keep_size=1GB است. +[Unit] +Description=Zareian MathApp: live WAL stream from 249 +After=zareian-pg-tunnel.service +BindsTo=zareian-pg-tunnel.service + +[Service] +User=administrator +ExecStart=/usr/lib/postgresql/14/bin/pg_receivewal -h 127.0.0.1 -p 15440 -U zareian_replicator \ + --no-password --slot=zareian_247 --synchronous --no-loop \ + -D /home/administrator/zareian-pgbackup/wal +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target diff --git a/payment/tests_audit_log.py b/payment/tests_audit_log.py new file mode 100644 index 0000000..0df6634 --- /dev/null +++ b/payment/tests_audit_log.py @@ -0,0 +1,26 @@ +""" +ردِ نتیجه‌ی درگاه در لاگِ فنی: کال‌بکِ بی‌سفارش و نتیجه‌ی تکراری فقط همین‌جا رد دارند (پولی جابه‌جا +نمی‌کنند، پس ردیفِ دیتابیس ندارند). نتیجه‌های پذیرفته‌شده در Transaction/StatusChangeLog‌اند. +""" +from django.test import TestCase + + +class PaymentAuditLogTests(TestCase): + def test_unknown_authority_callback_is_404_and_logged(self): + with self.assertLogs('payment.views', level='WARNING') as cm: + r = self.client.get('/api/payment/zarinpal/callback?Authority=A000NOPE&Status=OK') + self.assertEqual(r.status_code, 404) + self.assertIn('no order', cm.output[0]) + + def test_ignored_duplicate_paid_result_is_logged_as_warning(self): + from payment.models import Order + from payment.services import apply_order_payment_result + o = Order.objects.create(user_id=1, amount=1000, idempotency_key='k1', gateway_authority='A1') + with self.assertLogs('payment.services', level='INFO') as cm: + apply_order_payment_result(order=o, gateway='zarinpal', gateway_ref_id='R1', + new_status='paid', event='t') + self.assertIn('ignored=False', cm.output[0]) + with self.assertLogs('payment.services', level='INFO') as cm: + apply_order_payment_result(order=o, gateway='zarinpal', gateway_ref_id='R1', + new_status='paid', event='t') + self.assertIn('ignored=True', cm.output[0]) diff --git a/statuslog/backup.py b/statuslog/backup.py deleted file mode 100644 index cde0f34..0000000 --- a/statuslog/backup.py +++ /dev/null @@ -1,224 +0,0 @@ -""" -بکاپِ زمان‌بندی‌شده‌ی Postgres و آزمونِ بازیابی. - -چرا فایلِ جدا: تا امروز تنها بکاپِ خودکار همان بود که `deploy.sh` پیش از migrate می‌گیرد (۲ تای آخر)؛ -اگر چند روز دیپلویی نبود، بکاپِ تازه‌ای هم نبود. این‌جا یک بکاپِ روزانه، نگه‌داری، هوک برای کپیِ -بیرونِ ماشین، و بازیابی روی دیتابیسِ آزمایشی هست. وضعیتش در `status.json` کنارِ بکاپ‌ها می‌ماند و -`/api/health` از روی آن هشدار می‌دهد. - -آزموده‌شده: pg_dump/pg_restore روی Postgres ۱۴ی لپ‌تاپ (statuslog/tests_backup.py، تست‌های واقعی فقط -وقتی Postgres در دسترس باشد). آزموده‌نشده: روی سرور، زیر cron، با دیسکِ نزدیک به پُر، با -`BACKUP_OFFSITE_CMD` واقعی. -""" -import datetime -import hashlib -import json -import logging -import os -import shlex -import shutil -import subprocess -from pathlib import Path - -from django.apps import apps -from django.conf import settings -from django.db import connection - -logger = logging.getLogger(__name__) - -STATUS_NAME = 'status.json' -KEEP_DAILY = 7 # هفت روزِ آخر، از هر روز آخرین بکاپ -KEEP_WEEKLY = 4 # چهار هفته‌ی آخر، از هر هفته آخرین بکاپ -# جدول‌هایی که پرسشِ «پرداخت و ورود» به آن‌ها بسته است؛ شمارششان با بکاپ و بازیابی مقایسه می‌شود. -TRACKED = ['accounts.User', 'accounts.LoginEvent', 'payment.Order', 'payment.Transaction', - 'statuslog.StatusChangeLog'] - - -class BackupError(Exception): - pass - - -def backup_dir() -> Path: - return Path(settings.BACKUP_DIR) - - -def status_path() -> Path: - return backup_dir() / STATUS_NAME - - -def read_status() -> dict: - try: - return json.loads(status_path().read_text()) - except FileNotFoundError: - return {} - except (OSError, ValueError): - # فایلِ خراب همان «بکاپ نیست» نیست؛ صدا کن تا /api/health آن را نشان دهد. - logger.exception('status.json خوانده نشد: %s', status_path()) - return {'corrupt': True} - - -def write_status(update: dict) -> dict: - status = read_status() - status.pop('corrupt', None) - status.update(update) - backup_dir().mkdir(parents=True, exist_ok=True, mode=0o700) - tmp = status_path().with_suffix('.tmp') - tmp.write_text(json.dumps(status, ensure_ascii=False, indent=1)) - os.chmod(tmp, 0o600) - os.replace(tmp, status_path()) - return status - - -def require_postgres() -> dict: - db = settings.DATABASES['default'] - if db['ENGINE'] != 'django.db.backends.postgresql': - raise BackupError('بکاپِ زمان‌بندی‌شده فقط برای Postgres است (DB_NAME در .env ست نیست).') - return db - - -def _pg_env(db: dict) -> dict: - # رمز از env می‌رود نه argv: ps روی ماشینِ مشترک argv را به همه نشان می‌دهد. - return dict(os.environ, PGPASSWORD=db['PASSWORD']) - - -def _pg_args(db: dict) -> list: - return ['-h', db['HOST'], '-p', str(db['PORT']), '-U', db['USER']] - - -def table_counts(conn=None) -> dict: - conn = conn or connection - out = {} - with conn.cursor() as cur: - for label in TRACKED: - table = apps.get_model(label)._meta.db_table - cur.execute(f'SELECT count(*) FROM "{table}"') - out[label] = cur.fetchone()[0] - return out - - -def _run(cmd: list, env: dict, what: str) -> subprocess.CompletedProcess: - try: - return subprocess.run(cmd, env=env, check=True, capture_output=True, text=True) - except FileNotFoundError as exc: - raise BackupError(f'{what}: فرمانِ {cmd[0]} روی این ماشین نیست.') from exc - except subprocess.CalledProcessError as exc: - raise BackupError(f'{what} شکست خورد: {(exc.stderr or "").strip()[-500:]}') from exc - - -def _prune(directory: Path) -> list: - files = sorted(directory.glob('db-*.pgdump')) - by_day, by_week = {}, {} - for f in files: # مرتب‌شده؛ آخری برای هر کلید می‌ماند - stamp = datetime.datetime.strptime(f.name[3:18], '%Y%m%d-%H%M%S') - by_day[stamp.date()] = f - iso = stamp.isocalendar() - by_week[(iso[0], iso[1])] = f - keep = {v for _, v in sorted(by_day.items())[-KEEP_DAILY:]} - keep |= {v for _, v in sorted(by_week.items())[-KEEP_WEEKLY:]} - removed = [f for f in files if f not in keep] - for f in removed: - f.unlink() - return removed - - -def run_offsite(path: Path) -> dict: - """ - `BACKUP_OFFSITE_CMD` یک فرمان با جاینگهدار `{file}` است (مثلاً rsync/scp/rclone به مقصدی که ادمین - تعیین می‌کند). ست‌نبودنش خطا نیست ولی صریح «تنظیم نشده» ثبت می‌شود و health هشدار می‌دهد. - """ - template = os.environ.get('BACKUP_OFFSITE_CMD', '').strip() - if not template: - return {'state': 'not_configured'} - if '{file}' not in template: - return {'state': 'failed', 'error': 'BACKUP_OFFSITE_CMD باید {file} داشته باشد.'} - cmd = [part.replace('{file}', str(path)) for part in shlex.split(template)] - try: - subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=1800) - except (subprocess.SubprocessError, OSError) as exc: - detail = getattr(exc, 'stderr', '') or str(exc) - logger.error('کپیِ بیرونیِ بکاپ شکست خورد: %s', detail.strip()[-300:]) - return {'state': 'failed', 'error': detail.strip()[-300:]} - return {'state': 'ok', 'at': datetime.datetime.now().astimezone().isoformat(timespec='seconds')} - - -def run_backup() -> dict: - db = require_postgres() - directory = backup_dir() - directory.mkdir(parents=True, exist_ok=True, mode=0o700) - try: - previous = sorted(directory.glob('db-*.pgdump')) - need = (previous[-1].stat().st_size * 2) if previous else 100 * 2**20 - free = shutil.disk_usage(directory).free - if free < need: - raise BackupError(f'فضای خالی ({free // 2**20} MB) کمتر از لازم ({need // 2**20} MB) است.') - - before = table_counts() - now = datetime.datetime.now() - dst = directory / f'db-{now:%Y%m%d-%H%M%S}.pgdump' - _run(['pg_dump', '-Fc', *_pg_args(db), '-f', str(dst), db['NAME']], _pg_env(db), 'pg_dump') - os.chmod(dst, 0o600) - after = table_counts() - # فقط خواناییِ فایل؛ بازیابیِ واقعی را backup_verify می‌سنجد. - _run(['pg_restore', '--list', str(dst)], _pg_env(db), 'pg_restore --list') - digest = hashlib.sha256(dst.read_bytes()).hexdigest() - except BackupError as exc: - logger.error('بکاپِ زمان‌بندی‌شده شکست خورد: %s', exc) - write_status({'last_error': str(exc), - 'last_error_at': datetime.datetime.now().astimezone().isoformat(timespec='seconds')}) - raise - - offsite = run_offsite(dst) - removed = _prune(directory) - status = write_status({ - 'last_ok_at': now.astimezone().isoformat(timespec='seconds'), - 'file': dst.name, 'size_bytes': dst.stat().st_size, 'sha256': digest, - 'counts_before': before, 'counts_after': after, - 'offsite': offsite, 'pruned': [f.name for f in removed], - 'last_error': '', 'last_error_at': '', - }) - return status - - -def run_verify(file: str = '') -> dict: - """ - فایلِ بکاپ را روی دیتابیسِ آزمایشیِ جدا بازیابی می‌کند، شمارشِ جدول‌های پرداخت/ورود را با لحظه‌ی - بکاپ می‌سنجد و دیتابیسِ آزمایشی را می‌اندازد. به دیتابیسِ اصلی دست نمی‌زند. نقشِ برنامه باید - CREATEDB داشته باشد؛ نداشتنش با پیامِ روشن خطا می‌دهد، نه موفقیتِ ساختگی. - """ - db = require_postgres() - status = read_status() - name = file or status.get('file') - if not name: - raise BackupError('هنوز بکاپی ثبت نشده؛ اول backup_db.') - path = backup_dir() / name - if not path.exists(): - raise BackupError(f'فایلِ بکاپ نیست: {path}') - if hashlib.sha256(path.read_bytes()).hexdigest() != status.get('sha256') and not file: - raise BackupError('هشِ فایل با آنچه هنگامِ بکاپ ثبت شد نمی‌خواند (فایل عوض یا خراب شده).') - - scratch = f"{db['NAME']}_restorecheck" - env, args = _pg_env(db), _pg_args(db) - _run(['dropdb', '--if-exists', *args, scratch], env, 'dropdb') - _run(['createdb', *args, scratch], env, - 'createdb (نقشِ برنامه به CREATEDB نیاز دارد: ALTER ROLE CREATEDB)') - try: - _run(['pg_restore', '--no-owner', '--no-privileges', *args, '-d', scratch, str(path)], env, 'pg_restore') - restored = {} - for label in TRACKED: - table = apps.get_model(label)._meta.db_table - out = _run(['psql', '-tA', *args, '-d', scratch, '-c', f'SELECT count(*) FROM "{table}"'], env, 'psql') - restored[label] = int(out.stdout.strip()) - finally: - _run(['dropdb', '--if-exists', *args, scratch], env, 'dropdb') - - lo, hi = status.get('counts_before', {}), status.get('counts_after', {}) - bad = {k: {'restored': v, 'expected_between': [min(lo.get(k, v), hi.get(k, v)), max(lo.get(k, v), hi.get(k, v))]} - for k, v in restored.items() - if file == '' and not min(lo.get(k, v), hi.get(k, v)) <= v <= max(lo.get(k, v), hi.get(k, v))} - result = {'file': name, 'counts': restored, 'ok': not bad, 'mismatch': bad, - 'at': datetime.datetime.now().astimezone().isoformat(timespec='seconds')} - write_status({'restore_check': result}) - if bad: - logger.error('آزمونِ بازیابی ناهمخوان: %s', bad) - raise BackupError(f'شمارش‌ها بعد از بازیابی نمی‌خوانند: {bad}') - return result diff --git a/statuslog/management/__init__.py b/statuslog/management/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/statuslog/management/commands/__init__.py b/statuslog/management/commands/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/statuslog/management/commands/backup_db.py b/statuslog/management/commands/backup_db.py deleted file mode 100644 index 3601881..0000000 --- a/statuslog/management/commands/backup_db.py +++ /dev/null @@ -1,16 +0,0 @@ -from django.core.management.base import BaseCommand, CommandError - -from statuslog.backup import BackupError, run_backup - - -class Command(BaseCommand): - help = 'بکاپِ زمان‌بندی‌شده‌ی Postgres (pg_dump)، نگه‌داری ۷ روزه + ۴ هفته‌ای، هوکِ کپیِ بیرونی.' - - def handle(self, *args, **opts): - try: - s = run_backup() - except BackupError as exc: - raise CommandError(str(exc)) - self.stdout.write(f"{s['file']} {s['size_bytes'] // 1024} KB offsite={s['offsite']['state']}") - if s['offsite']['state'] == 'failed': - raise CommandError(f"بکاپِ محلی ساخته شد ولی کپیِ بیرونی شکست خورد: {s['offsite'].get('error')}") diff --git a/statuslog/management/commands/backup_verify.py b/statuslog/management/commands/backup_verify.py deleted file mode 100644 index 534b924..0000000 --- a/statuslog/management/commands/backup_verify.py +++ /dev/null @@ -1,17 +0,0 @@ -from django.core.management.base import BaseCommand, CommandError - -from statuslog.backup import BackupError, run_verify - - -class Command(BaseCommand): - help = 'آخرین بکاپ را روی دیتابیسِ آزمایشیِ جدا بازیابی و شمارشِ جدول‌های پرداخت/ورود را می‌سنجد.' - - def add_arguments(self, parser): - parser.add_argument('--file', default='', help='نامِ یک فایلِ مشخص در پوشه‌ی بکاپ (پیش‌فرض: آخرین).') - - def handle(self, *args, **opts): - try: - r = run_verify(opts['file']) - except BackupError as exc: - raise CommandError(str(exc)) - self.stdout.write(f"بازیابی از {r['file']} سالم بود: {r['counts']}") diff --git a/statuslog/tests_backup.py b/statuslog/tests_backup.py deleted file mode 100644 index afc4d1b..0000000 --- a/statuslog/tests_backup.py +++ /dev/null @@ -1,114 +0,0 @@ -""" -بکاپِ زمان‌بندی‌شده: نگه‌داری، هوکِ بیرونی، هشدارِ health، و لاگِ پرداخت. -خودِ pg_dump/pg_restore اینجا اجرا نمی‌شود (تست‌ها روی SQLite‌اند)؛ آن را یک اجرای دستی روی Postgresِ -لپ‌تاپ سنجید (شرحش در docs/operations.md). آزموده‌نشده: زیر cron روی سرور. -""" -import datetime -import json -import tempfile -from pathlib import Path -from unittest import mock - -from django.test import SimpleTestCase, TestCase, override_settings - -from statuslog import backup - - -def _touch(d: Path, stamp: str): - f = d / f'db-{stamp}.pgdump' - f.write_bytes(b'x') - return f - - -class PruneTests(SimpleTestCase): - def test_keeps_last_7_days_and_4_weeks_only(self): - with tempfile.TemporaryDirectory() as t: - d = Path(t) - start = datetime.datetime(2026, 8, 1, 2, 0, 0) - for i in range(60): # شصت روز، هر روز یک بکاپ - _touch(d, (start + datetime.timedelta(days=i)).strftime('%Y%m%d-%H%M%S')) - removed = backup._prune(d) - left = sorted(p.name for p in d.glob('db-*.pgdump')) - self.assertEqual(len(left) + len(removed), 60) - self.assertIn('db-20260929-020000.pgdump', left) # آخرین - self.assertLessEqual(len(left), 7 + 4) - self.assertGreaterEqual(len(left), 7) - - def test_two_backups_same_day_keep_latest(self): - with tempfile.TemporaryDirectory() as t: - d = Path(t) - _touch(d, '20261001-020000') - _touch(d, '20261001-140000') - backup._prune(d) - self.assertEqual([p.name for p in d.glob('db-*')], ['db-20261001-140000.pgdump']) - - -class OffsiteTests(SimpleTestCase): - def test_not_configured_is_explicit(self): - with mock.patch.dict('os.environ', {'BACKUP_OFFSITE_CMD': ''}): - self.assertEqual(backup.run_offsite(Path('/x')), {'state': 'not_configured'}) - - def test_placeholder_required(self): - with mock.patch.dict('os.environ', {'BACKUP_OFFSITE_CMD': 'rsync a b'}): - self.assertEqual(backup.run_offsite(Path('/x'))['state'], 'failed') - - def test_failing_command_is_reported_not_swallowed(self): - with mock.patch.dict('os.environ', {'BACKUP_OFFSITE_CMD': 'false {file}'}): - self.assertEqual(backup.run_offsite(Path('/x'))['state'], 'failed') - - def test_ok(self): - with mock.patch.dict('os.environ', {'BACKUP_OFFSITE_CMD': 'true {file}'}): - self.assertEqual(backup.run_offsite(Path('/x'))['state'], 'ok') - - -class HealthWarningTests(SimpleTestCase): - def _warn(self, status, **settings_kw): - from config import health - with tempfile.TemporaryDirectory() as t, override_settings(BACKUP_DIR=t, DEBUG=False, **settings_kw): - if status is not None: - (Path(t) / 'status.json').write_text(json.dumps(status)) - with mock.patch.object(health.connection, 'vendor', 'postgresql'): - return health._backup_warnings() - - def test_no_status_warns(self): - self.assertTrue(any('هیچ بکاپ' in w for w in self._warn(None))) - - def test_stale_backup_warns(self): - old = (datetime.datetime.now().astimezone() - datetime.timedelta(hours=40)).isoformat() - self.assertTrue(any('ساعت پیش' in w for w in self._warn({'last_ok_at': old}))) - - def test_fully_healthy_has_no_warning(self): - now = datetime.datetime.now().astimezone().isoformat() - st = {'last_ok_at': now, 'offsite': {'state': 'ok'}, 'restore_check': {'ok': True, 'at': now}} - self.assertEqual(self._warn(st), []) - - def test_offsite_missing_and_restore_untested_warn(self): - now = datetime.datetime.now().astimezone().isoformat() - w = self._warn({'last_ok_at': now, 'offsite': {'state': 'not_configured'}}) - self.assertTrue(any('بیرون از این ماشین' in x for x in w)) - self.assertTrue(any('آزموده نشده' in x for x in w)) - - def test_sqlite_dev_is_silent(self): - from config import health - self.assertEqual(health._backup_warnings(), []) - - -class PaymentAuditLogTests(TestCase): - def test_unknown_authority_callback_is_404_and_logged(self): - with self.assertLogs('payment.views', level='WARNING') as cm: - r = self.client.get('/api/payment/zarinpal/callback?Authority=A000NOPE&Status=OK') - self.assertEqual(r.status_code, 404) - self.assertIn('no order', cm.output[0]) - - def test_ignored_duplicate_paid_result_is_logged_as_warning(self): - from payment.models import Order - from payment.services import apply_order_payment_result - o = Order.objects.create(user_id=1, amount=1000, idempotency_key='k1', gateway_authority='A1') - with self.assertLogs('payment.services', level='INFO') as cm: - apply_order_payment_result(order=o, gateway='zarinpal', gateway_ref_id='R1', - new_status='paid', event='t') - self.assertIn('ignored=False', cm.output[0]) - with self.assertLogs('payment.services', level='INFO') as cm: - apply_order_payment_result(order=o, gateway='zarinpal', gateway_ref_id='R1', - new_status='paid', event='t') - self.assertIn('ignored=True', cm.output[0])