Compare commits

..

8 Commits

Author SHA1 Message Date
43e02adf37 216 sshd: LoginGraceTime back to 2m (30s silently cut off people typing a long password)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 00:59:59 +03:30
e25ffb5575 CLAUDE.md: 216 password is the AdminUser-Password line
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 00:48:23 +03:30
eb03b467d4 216: shared admin account mathapp-admin, temp removed, hostname mathapp
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:50:17 +03:30
f2c57c1ad1 docs: step-by-step cut-over to 216 with rollback, what waits on others, after-cut-over list
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:18:40 +03:30
f6d779255d docs: what was tested on 216 (end-to-end user flow, restore from the 247 encrypted copy)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:08:38 +03:30
4cd859b5fa 216 backups encrypted on 216: pgBackRest (AES-256) + age-encrypted payment CSVs, pushed write-only to 247 with rrsync; health watches pg_stat_archiver and OFFSITE_SYNC_STATUS_FILE; 216 scripts/configs in ops/server216; plaintext pg216 pipeline removed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 19:43:00 +03:30
dd98f16d12 Live backup of 216 on 247 (zareian-pg216-* units, Postgres 16 tools in our dir, SCRAM hash only on 216), backup scripts take PGB_ROOT/PGB_BIN/PGB_PORT with 249 defaults, anti-bot layers documented
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 19:22:56 +03:30
bd329f83ae Dedicated server 78.38.35.216: state and cut-over order in docs, CLAUDE.md entry, password snippet for two Password lines, archive-app-log.sh finds its project dir
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 19:06:04 +03:30
29 changed files with 506 additions and 6 deletions

View File

@@ -39,6 +39,9 @@ DB_PORT=5432
# اسلاتِ تکثیری که سرورِ بکاپ از آن کپیِ زنده می‌گیرد (ops/pgbackup/). روی سرور: zareian_247.
# خالی روی Postgres = کپیِ بیرونی نداریم و /api/health هشدار می‌دهد. روی لپ‌تاپ خالی.
OFFSITE_WAL_SLOT=
# سرورِ اختصاصی (۲۱۶): فایلی که tadrisino-offsite-sync زمانِ آخرین ارسالِ بکاپِ رمزشده به ۲۴۷ را در آن می‌نویسد.
# روی ۲۱۶: /var/lib/tadrisino-backup/offsite-synced-at (بر OFFSITE_WAL_SLOT مقدم است). روی لپ‌تاپ خالی.
OFFSITE_SYNC_STATUS_FILE=
# ═══════════════════════════════════════════════════════════════════════
# ۲) دامنه‌ها — دو آدرس، بقیه از آن‌ها ساخته می‌شوند

View File

@@ -63,10 +63,18 @@ other people's home directories (`aria`, `artina`, `behdad`, `zahra`, `git`, `gi
other nginx site. `metabase` and Artina's `celerybeat` crash-loop and eat ~3 of 8 cores; that is a
request to the server admin, not something to fix.
**`78.38.35.247` (backup server)**, same account and password: ours is only `~/zareian-pgbackup/`
and the `zareian-pg-*` / `zareian-applog-pull` units. It also runs other teams' services and its
**`78.38.35.247` (backup server)**, same account and password: ours is only `~/zareian-pgbackup/` (249's
live copy), `~/zareian-pgbackrest-216/` (216's backups, encrypted on 216; 247 never holds the key), our lines in
`~/.ssh/authorized_keys` and `~/.pgpass`, `~/.ssh/zareian_*`, and the `zareian-pg-*` / `zareian-applog-pull` units. It also runs other teams' services and its
own `postgresql@14-main`, which we never use.
**`78.38.35.216` (hostname `mathapp`, new dedicated VM, not yet serving users)**, account `zareian` (sudo; the
senior and the company owner share `mathapp-admin`, also sudo; no other logins): the whole machine is ours. The app runs as the non-sudo user `mathapp` under `/home/mathapp/`; same unit names as on 249.
Both accounts use the password on the `AdminUser-Password` line of the password file (the `Password` line under
`NewServer` is older). State and the cut-over order:
`docs/operations.md` «سرورِ اختصاصیِ جدید». Until cut-over, 249 stays the live server and every rule below applies
there unchanged; on 216, rules 1, 5, 6, 8 and 10 still apply.
### Rules (not negotiable)
1. **Read-only first.** Before changing anything, look: `systemctl status`, `ls`, `git -C … log -1`,
@@ -114,7 +122,8 @@ session. Take only the password line, everything after the first `: ` (stripping
once sent the whole line and SSH refused it):
```bash
sed -n '/^Password/{s/^[^:]*: *//;p;}' ~/Desktop/'رمز سرور.txt' > "$SCRATCH/.pw" && chmod 600 "$SCRATCH/.pw"
sed -n '/^Password/{s/^[^:]*: *//;p;q;}' ~/Desktop/'رمز سرور.txt' | tr -d '\r\n' > "$SCRATCH/.pw" && chmod 600 "$SCRATCH/.pw" # 249/247 (first Password line)
awk '/^AdminUser-Password/{sub(/^[^:]*: */,"");printf "%s",$0;exit}' ~/Desktop/'رمز سرور.txt' | tr -d '\r\n' > "$SCRATCH/.pwn" && chmod 600 "$SCRATCH/.pwn" # 216
sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 '<command>'
sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 'sudo -S -p "" <command>' < "$SCRATCH/.pw"
rm -P "$SCRATCH/.pw" # as soon as the work is done (macOS has no shred); recreate it next time

View File

@@ -14,6 +14,7 @@ GET /api/health — «آیا این سرور واقعاً سالم است؟»
"""
import logging
import os
import time
from datetime import timedelta
from django.conf import settings
@@ -160,6 +161,8 @@ def _offsite_backup() -> dict:
"""
if connection.vendor != 'postgresql':
return {'state': 'not_postgres'}
if settings.OFFSITE_SYNC_STATUS_FILE:
return _pushed_backup()
slot = settings.OFFSITE_WAL_SLOT
if not slot:
return {'state': 'not_configured'}
@@ -193,6 +196,45 @@ def _slot_state(row) -> dict:
return {'state': state, 'lagBytes': lag}
def _pushed_backup() -> dict:
"""
بکاپِ رمزشده‌ی ۲۱۶: pgBackRest هر WAL را با archive_command آرشیو می‌کند (pg_stat_archiver) و
`tadrisino-offsite-sync` مخزن را هر دقیقه به ۲۴۷ می‌فرستد و زمانش را در OFFSITE_SYNC_STATUS_FILE می‌نویسد.
کهنگیِ آخرین آرشیو هشدار نیست: دیتابیسِ بی‌تغییر WALِ تازه‌ای ندارد که آرشیو شود.
"""
try:
with connection.cursor() as cursor:
cursor.execute('SELECT last_archived_time, last_failed_time FROM pg_stat_archiver')
last_archived, last_failed = cursor.fetchone()
except Exception as exc: # noqa: BLE001
return {'state': 'error', 'error': type(exc).__name__}
try:
with open(settings.OFFSITE_SYNC_STATUS_FILE) as f:
synced_at = int(f.read().strip())
except FileNotFoundError:
synced_at = None
except (OSError, ValueError) as exc:
return {'state': 'error', 'error': type(exc).__name__}
return _pushed_state(last_archived, last_failed, synced_at, time.time())
def _pushed_state(last_archived, last_failed, synced_at, now) -> dict:
age = None if synced_at is None else int(now - synced_at)
if last_failed is not None and (last_archived is None or last_failed > last_archived):
state = 'archive_failing'
elif age is None:
state = 'never_synced'
elif age > settings.OFFSITE_SYNC_MAX_AGE_SECONDS:
state = 'sync_stale'
else:
state = 'ok'
return {
'state': state,
'syncAgeSeconds': age,
'lastArchivedAt': last_archived.isoformat() if last_archived else None,
}
# اتصال‌هایی بیرون از poolِ workerها: pg_receivewal و pg_basebackupِ سرورِ بکاپ (۲)، pg_dump و
# migrateِ deploy.sh (۲)، فرمانِ مدیریتی یا psqlِ دستی (۲)، و workerی که gunicorn با max_requests
# عوض می‌کند و تا بسته شدنِ قبلی هم‌زمان بالاست (یک pool).
@@ -267,6 +309,9 @@ _OFFSITE_MESSAGES = {
'lagging': 'سرورِ بکاپ از دیتابیس عقب است.',
'lost': 'سرورِ بکاپ آن‌قدر قطع بود که زنجیره‌ی WAL برید؛ روی آن سرور اسلات و base backupِ تازه لازم است.',
'error': 'وضعیتِ کپیِ زنده‌ی دیتابیس خوانده نشد.',
'archive_failing': 'آرشیوِ WAL (pgBackRest) شکست می‌خورد و تغییراتِ تازه بکاپ نمی‌شوند: sudo -u postgres pgbackrest --stanza=zareian check',
'never_synced': 'بکاپِ رمزشده هنوز هیچ‌وقت به سرورِ بیرونی (۲۴۷) نرسیده (فایلِ OFFSITE_SYNC_STATUS_FILE نیست).',
'sync_stale': 'ارسالِ بکاپِ رمزشده به ۲۴۷ عقب افتاده: journalctl -u tadrisino-offsite-sync',
}

View File

@@ -1078,6 +1078,12 @@ LOG_DIR = os.environ.get('LOG_DIR', '')
OFFSITE_WAL_SLOT = os.environ.get('OFFSITE_WAL_SLOT', '')
# بیش از این عقب‌ماندن هشدار است. ۶۴ مگ ≈ چهار فایلِ WAL؛ در ترافیکِ عادی عقب‌ماندگی نزدیکِ صفر است.
OFFSITE_WAL_MAX_LAG_BYTES = 64 * 2**20
# سرورِ اختصاصی (۲۱۶): بکاپِ رمزشده‌ی pgBackRest؛ `tadrisino-offsite-sync` هر دقیقه آن را به ۲۴۷ می‌فرستد و زمانِ
# آخرین ارسالِ موفق (epoch) را در این فایل می‌نویسد (docs/operations.md «سرورِ اختصاصیِ جدید»). ست باشد، بر
# OFFSITE_WAL_SLOT مقدم است. OFFSITE_WAL_SLOT را فقط ۲۴۹ دارد و با سوئیچ به ۲۱۶ همراهِ _slot_state حذف می‌شود.
OFFSITE_SYNC_STATUS_FILE = os.environ.get('OFFSITE_SYNC_STATUS_FILE', '')
# ارسال هر دقیقه است؛ ۱۰ دقیقه بی‌ارسال یعنی چند اجرای پشتِ سرِ هم شکست خورده.
OFFSITE_SYNC_MAX_AGE_SECONDS = 600
# چرخشِ فایلِ لاگ: 'size' (پیش‌فرض، Docker و محلی) = ۶ فایلِ ۱۰ مگی و قدیمی‌ها پاک می‌شوند؛
# 'external' (سرور) = برنامه هرگز پاک نمی‌کند، `ops/applogs/archive-app-log.sh` هر شب app.log را جابه‌جا و

View File

@@ -3,6 +3,8 @@
پرسشِ واقعی از pg_replication_slots این‌جا اجرا نمی‌شود (تست‌ها روی SQLite‌اند)؛ روی سرور با اسلاتِ
واقعی دیده شد (docs/operations.md «بکاپ»).
"""
from datetime import datetime, timedelta, timezone
from django.test import SimpleTestCase, override_settings
from config import health
@@ -27,13 +29,45 @@ class SlotStateTests(SimpleTestCase):
self.assertEqual(health._slot_state((True, 'reserved', 100))['state'], 'ok')
@override_settings(OFFSITE_SYNC_MAX_AGE_SECONDS=600)
class PushedStateTests(SimpleTestCase):
"""بکاپِ رمزشده‌ی ۲۱۶ (_pushed_state). پرسشِ pg_stat_archiver و خواندنِ فایل روی سرور دیده شد، نه این‌جا."""
T0 = datetime(2026, 10, 17, 16, 0, tzinfo=timezone.utc)
def test_recent_sync_is_ok(self):
r = health._pushed_state(self.T0, None, 1000, now=1060)
self.assertEqual((r['state'], r['syncAgeSeconds']), ('ok', 60))
self.assertEqual(r['lastArchivedAt'], self.T0.isoformat())
def test_old_sync_is_stale(self):
self.assertEqual(health._pushed_state(self.T0, None, 1000, now=1601)['state'], 'sync_stale')
self.assertEqual(health._pushed_state(self.T0, None, 1000, now=1600)['state'], 'ok')
def test_no_status_file_is_never_synced(self):
self.assertEqual(health._pushed_state(self.T0, None, None, now=1000)['state'], 'never_synced')
def test_failure_after_last_success_wins(self):
later = self.T0 + timedelta(minutes=1)
self.assertEqual(health._pushed_state(self.T0, later, 1000, now=1001)['state'], 'archive_failing')
self.assertEqual(health._pushed_state(None, later, 1000, now=1001)['state'], 'archive_failing')
def test_old_failure_then_success_is_ok(self):
earlier = self.T0 - timedelta(minutes=1)
self.assertEqual(health._pushed_state(self.T0, earlier, 1000, now=1001)['state'], 'ok')
def test_idle_database_with_no_archive_yet_is_ok(self):
self.assertEqual(health._pushed_state(None, None, 1000, now=1001)['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'):
for state in ('not_configured', 'slot_missing', 'disconnected', 'lagging', 'lost', 'error',
'archive_failing', 'never_synced', 'sync_stale'):
self.assertEqual(len(health._offsite_warnings({'state': state})), 1, state)
def test_sqlite_reports_not_postgres(self):

View File

@@ -382,6 +382,104 @@ the servers»):
- سنجیده (۲۰۲۶-۱۰-۱۳): اپ در Chrome، بی‌ورود و با ورود، همه‌ی تب‌ها و آزمونِ یک جزیره، بی هیچ خطای CSP. **نسنجیده:** اپِ اندروید
(WebView همان CSP را می‌گیرد) و صفحه‌ی `pay.html` تا درگاه.
## سرورِ اختصاصیِ جدید (`78.38.35.216`): آماده، هنوز سوئیچ نشده
اپ به یک ماشینِ مجازیِ اختصاصی منتقل می‌شود. کاربران هنوز از ۲۴۹ سرو می‌شوند؛ ArvanCloud هنوز به ۲۴۹ اشاره می‌کند.
| | |
|---|---|
| ماشین | VMware، Ubuntu 24.04، ۸ هسته، ۱۲ گیگ رم، دیسکِ ۹۷ گیگ؛ hostname `mathapp`، IPِ داخلی `192.168.200.163` (۲۴۹ = `.164`)، IPِ خروجی `78.38.35.228` |
| ورود | دو حساب با sudo: `zareian` (صاحبِ محصول) و `mathapp-admin` (مشترکِ سینیور و صاحبِ شرکت؛ رمزش در فایلِ رمز زیرِ `AdminUser`). `temp` حذف شد (۲۰۲۶-۱۰-۱۷؛ authorized_keysش در `/home/zareian/backups/`). ورودِ root بسته، `AllowUsers zareian mathapp-admin`، forwarding بسته (`10-tadrisino.conf`) |
| فایل‌ها | نسخه‌ی اصلیِ همه‌ی اسکریپت‌ها و تنظیم‌های این سرور در `ops/server216/` (بی‌کلید). تغییر: فایلِ ریپو، بعد کپی روی سرور |
| ضدِ ربات | ۱) `tadrisino-blocklist.timer`: هر روز FireHOL level1 (Spamhaus، DShield، …) در ipsetِ `tadrisino-block`، همه‌ی پورت‌ها؛ شبکه‌ی داخلی، `78.38.35.0/24` و رنج‌های آروان هرگز در آن نیستند. ۲) `ufw limit 22`: بیش از ۶ اتصال در ۳۰ ثانیه رد. ۳) fail2ban پله‌ای: ۶ اشتباه در ۱۰ دقیقه → ۱۰ دقیقه، هر تکرار دو برابر تا یک هفته؛ `recidive`: ۵ بار در روز → یک هفته همه‌ی پورت‌ها؛ `192.168.200.0/24` هرگز. آزاد کردن: `sudo fail2ban-client set sshd unbanip <IP>` |
| بکاپ | **رمزنگاری روی همین سرور، ۲۴۷ فقط فایلِ رمزشده می‌بیند** (۲۴۷ مشترک است و هر کس `administrator` را دارد root است). pgBackRest با `repo1-cipher-type=aes-256-cbc`: آرشیوِ پیوسته‌ی WAL (`archive_timeout=60`، یعنی حداکثر یک دقیقه تغییرِ آرشیونشده)، `tadrisino-pgbackrest-full` یکشنبه‌ها، `-diff` هر روز، دو نسخه‌ی کامل نگه داشته می‌شود. CSVهای پرداخت هر ۶ ساعت با age رمز می‌شوند (`tadrisino-export-payment-events`). `tadrisino-offsite-sync` هر دقیقه `/var/lib/pgbackrest` و `payment-log` را به `~/zareian-pgbackrest-216/` روی ۲۴۷ می‌فرستد؛ کلیدِ postgres در `authorized_keys`ِ ۲۴۷ با `from=192.168.200.163,restrict,command="rrsync -wo …"` فقط می‌تواند در همان پوشه بنویسد (نه بخواند، نه شِل). `/api/health` → `offsiteBackup` از `pg_stat_archiver` و `OFFSITE_SYNC_STATUS_FILE` |
| کلیدهای بازیابی | `/etc/tadrisino-backup/RECOVERY-KEYS` (root، 600): کلیدِ pgBackRest و کلیدِ age. **بی‌آن‌ها بکاپِ ۲۴۷ خواندنی نیست**؛ یک نسخه بیرون از سرورها نزدِ صاحبِ محصول لازم است: `ssh -t zareian@78.38.35.216 sudo cat /etc/tadrisino-backup/RECOVERY-KEYS` در ترمینالِ خودش (نه در چت) |
| آزمونِ بازیابی | `sudo -u postgres tadrisino-restore-check` (ماهی یک بار): بازیابی در پوشه‌ی موقت و مقایسه‌ی شمارش‌ها با دیتابیسِ زنده. بازیابیِ واقعی از ۲۴۷ (اگر ۲۱۶ از دست رفت): پوشه‌ی `repo/` را از ۲۴۷ کپی، `pgbackrest.conf` با کلید از RECOVERY-KEYS، `pgbackrest --stanza=zareian restore` |
| اپ | کاربرِ بی‌sudoِ `mathapp`؛ `/home/mathapp/mathapp-v2` و `/home/mathapp/mathapp_frontend_new` |
| سرویس‌ها | `zareian-mathapp` (= `gunicorn-mathapp`، با `RuntimeDirectory`)، `zareian-redis` (6390)، `postgresql@16-zareian` (5440)، `nginx`، `zareian-applog-archive.timer` |
| رمزها | رمزِ Postgres و Redis روی این سرور تازه ساخته شده (`.pg_password`، `.redis_password` کنارِ `.env`)، نه کپیِ ۲۴۹ |
| فایروال | ufw فقط 22/80/443. فایروالِ شبکه‌ی شرکت 443ِ ورودی را هنوز می‌بندد (80 باز است) |
| بسته‌ها | apt از `mirror.arvancloud.ir`، pip از `mirror-pypi.runflare.com` (`~mathapp/.config/pip/pip.conf`)، npm از `mirror-npm.runflare.com`؛ `files.pythonhosted.org` و `registry.npmjs.org` از این سرور بسته‌اند |
سنجیده: `/api/health` با همه‌ی جزئیات ok، آزمونِ بازیابی از بکاپِ رمزشده با همان شمارش‌ها، فایل‌های ۲۴۷ رمزشده (`Salted__`،
`age-encryption.org/v1`)، کلیدِ ۲۱۶ روی ۲۴۷ نه می‌خواند نه شِل می‌گیرد، کپیِ ۲۴۷ با `rsync --checksum` عینِ مخزنِ محلی، Qdrant از این سرور وصل، فرانت ۲۰۰، ریبوت و
بالا آمدنِ خودکارِ همه‌ی سرویس‌ها، درخواستِ مستقیم به IP ۴۰۳ (nginx IPِ واقعی را می‌بیند)، بازیابیِ کامل از نسخه‌ی رمزشده‌ی
خودِ ۲۴۷ (دیتابیس و CSV؛ عقب‌ماندگیِ طراحی‌شده حداکثر ~۲ دقیقه). سفرِ کاملِ کاربر از nginx+TLS روی خودِ سرور (۲۹ مورد): فرانت و
هدرهای امنیتی، پیامکِ واقعیِ کاوه‌نگار و ورود، نقشه، آزمونِ جزیره و پایانش، پیِ زنده، سفارش و redirect به StartPayِ زرین‌پال (بی‌پرداخت)،
خروج و باطل شدنِ توکن. کاوه‌نگار و GapGPT از IPِ خروجیِ این سرور پذیرفته‌اند. نسنجیده: هیچ درخواستی از مسیرِ ArvanCloud
(۴۴۳ بسته است)، پرداختِ کامل، خریدِ بازار.
### چه چیزی منتظرِ دیگران است
درخواست از سینیور فرستاده شده (۲۰۲۶-۱۰-۱۷): ۱) 443ِ ورودی روی فایروالِ شرکت؛ ۲) (انجام شد: `temp` حذف و `mathapp-admin` ساخته شد)؛ ۳) عوض کردنِ originِ
`math.tadrisino2.ir` و `mathapi.tadrisino2.ir` در پنلِ ArvanCloud به `78.38.35.216` (ما به پنل دسترسی نداریم؛ هم‌زمان با قدمِ ۴ پایین)؛
۴) private کردنِ مخزن در Gitea؛ ۵) ۱۲ هسته و رمِ بیشتر (اختیاری؛ سرور ریبوت را تحمل می‌کند، تست شده). زرین‌پال و بازار به
دامنه بسته‌اند نه IP، پس در پنلِ آن‌ها چیزی عوض نمی‌شود.
### سوئیچ، قدم‌به‌قدم (وقتی سینیور گفت آماده است)
1. **پیش‌شرط:** از لپ‌تاپ `curl -sk -o /dev/null -w '%{http_code}' https://78.38.35.216/` باید ۴۰۳ بدهد (نه ۰۰۰: یعنی 443 باز است و
nginx فقط آروان را راه می‌دهد). کامیت‌ها push شده باشند و `deploy.sh` روی هر دو سرور اجرا شده باشد (کدِ دو طرف یکی).
2. **۲۴۹ را بخوابان:** `sudo systemctl stop zareian-mathapp` (از این لحظه کاربران خطا می‌بینند تا قدمِ ۵؛ کلِ کار ~۱۵ دقیقه).
3. **دیتابیسِ نهایی:** `sudo -u postgres pg_dump -p 5440 -Fc zareian_mathapp` روی ۲۴۹، با pipe از لپ‌تاپ به
`/home/zareian/stage/` روی ۲۱۶ (مسیرِ داخلیِ ۲۴۹→۲۱۶ کلید ندارد).
4. **روی ۲۱۶:** `systemctl stop zareian-mathapp`؛ `dropdb -p 5440 zareian_mathapp && createdb -p 5440 -O zareian_app zareian_mathapp` (با
`sudo -u postgres`)؛ `pg_restore -p 5440 -d zareian_mathapp --no-owner --role=zareian_app --exit-on-error`؛ شمارشِ `accounts_user`،
`content_question`، `payment_order`، `payment_paymentevent`، `content_useranswer` با همان پرسش روی ۲۴۹ یکی باشد؛ `systemctl start
zareian-mathapp`؛ health با `X-Internal-Key` بی‌هشدار. داده‌های تستِ ۲۰۲۶-۱۰-۱۷ روی ۲۱۶ با همین قدم پاک می‌شوند.
5. **سینیور origin را در آروان عوض می‌کند.** بعد از بیرون: `https://mathapi.tadrisino2.ir/api/health` ok؛ در
`/var/log/nginx/access.log`ِ ۲۱۶ IPهای آروان دیده شوند؛ ورود در Chrome (روشِ OTPِ ادمین، بخشِ «ورود بی‌پیامک») و یک آزمون؛ کاربرِ
اندروید (WebView و `pay.html`) هم یک بار.
6. **بکاپِ تازه:** `sudo systemctl start tadrisino-pgbackrest-full` و بعد `tadrisino-offsite-sync`؛ `sudo -u postgres tadrisino-restore-check`.
7. **۲۴۹ را کنار بگذار (پاک نکن):** `sudo systemctl disable zareian-mathapp` روی ۲۴۹؛ اسلات را بردار تا WAL روی دیسکِ مشترک جمع نشود:
`sudo -u postgres psql -p 5440 -c "select pg_drop_replication_slot('zareian_247')"`؛ روی ۲۴۷ `systemctl disable --now zareian-pg-tunnel
zareian-pg-wal zareian-pg-basebackup.timer zareian-pg-export-events.timer zareian-applog-pull.timer`. دیتابیسِ ۲۴۹ یک هفته برای برگشت می‌ماند.
8. **برگشت (اگر قدمِ ۵ خراب شد):** origin در آروان دوباره ۲۴۹، و روی ۲۴۹ `systemctl start zareian-mathapp`. فقط تا وقتی درست است که ۲۱۶
نوشتنِ واقعی (پرداخت، ثبت‌نام) نگرفته؛ بعد از آن اول dumpِ ۲۱۶ به ۲۴۹.
9. **اسناد:** CLAUDE.md (سرورِ زنده = ۲۱۶؛ رویه‌ی دیپلو با `zareian@78.38.35.216` و `sudo -u mathapp ./deploy.sh`)، این بخش، و
`ops/nginx/` با مسیرهای `/home/mathapp` (۲۱۶ نسخه‌ی sed‌شده را دارد).
### بعد از سوئیچ
- یک هفته بعد، با تأییدِ صاحبِ محصول: پاک کردنِ بکاپ‌های رمزنگاری‌نشده‌ی ۲۴۹ روی ۲۴۷ (`~/zareian-pgbackup`، یونیت‌های `zareian-pg-*`)
و حذفِ `_slot_state`/`OFFSITE_WAL_SLOT` از `config/health.py` و `settings.py`.
- لاگِ اپ: آرشیوِ شبانه روی ۲۱۶ هست (`zareian-applog-archive.timer`) ولی هنوز به ۲۴۷ نمی‌رود؛ راهِ پیشنهادی: افزودنِ
`logs/archive` به `tadrisino-offsite-sync` (همان rrsync، پوشه‌ی `app-logs/`).
- ورود با کلیدِ SSH برای `zareian` (صاحبِ محصول خواسته فعلاً بماند؛ روی لپ‌تاپ `ssh-keygen -t ed25519` و `ssh-copy-id`)، بعد
`PasswordAuthentication no` با `Match Address 192.168.200.0/24` برای ورودِ با رمز از شبکه‌ی داخلی.
- توکنِ فقط‌خواندنیِ Gitea برای `deploy.sh` روی ۲۱۶ (بعد از private شدنِ مخزن، `git fetch` بی‌رمز دیگر کار نمی‌کند).
- `CORS_ALLOWED_ORIGINS` در `.env`ِ سرور هنوز `localhost:5174` دارد؛ حذفش.
- برنامه‌ی صاحبِ محصول: مرورِ کاملِ یک فهرستِ امنیتیِ استاندارد (OWASP Top 10 / API Top 10 برای اپ، CIS Benchmark برای سرور، همراهِ
`docs/security-before-production.md`)، بعد لودتست روی ۲۱۶ (`tools/loadtest/README.md`؛ با هسته‌ی بیشتر: ۸ worker و `DB_POOL_MAX_SIZE` 6).
### Qdrant
**وضعِ فعلی Qdrant:** بردارهای RAG روی `https://qdrant.tadrisino2.ir` هستند (پشت ArvanCloud؛ سرورِ اصلی از بیرون پیدا
نیست و در فهرستِ سرورهای ما نیست. `rag_catalog` آن را سرورِ تیمِ دیگر می‌داند). نسخهٔ 1.17.0، ۳۴ collection، حدود
۱۰۸۸۰ نقطه، همه با بُعد ۱۵۳۶ و Cosine.
**هدف:** همهٔ کتاب‌های ریاضی‌ای که در Qdrant هست (ریاضی، حسابان، هندسه، آمار و احتمال، گسسته؛ پایه‌های سوم تا دوازدهم) روی یک
Qdrant اختصاصی ببریم و اپ را به آن وصل کنیم، بدون ingest دوباره، چون ingest هزینهٔ GapGPT دارد.
**کدام collectionها (۱۵ تا، حدود ۵۶۸۰ نقطه):**
- ریاضی سوم، چهارم، پنجم، ششم، هفتم، هشتم، نهم، دهم.
- حسابان یازدهم، دوازدهم.
- هندسه دهم، یازدهم، دوازدهم.
- آمار و احتمال یازدهم.
- گسسته دوازدهم.
ریاضیِ سوم، ششم و هفتم را تیمِ دیگر ingest کرده، و صاحبِ محصول خواسته هر سه منتقل شوند. بقیهٔ collectionها (علوم، فارسی،
مطالعات اجتماعی، هدیهٔ آسمان، پیام‌های آسمان) در این برنامه نیستند.
**اصولِ انتقال:**
- از هر collection یک snapshot بگیرید، فایلِ آن را منتقل و روی Qdrant جدید بارگذاری کنید (بدون فراخوانیِ OpenAI).
- بعد از انتقال: تعدادِ نقاط، بُعد و Cosine یکی باشند؛ یک بردارِ ذخیره‌شده را به‌عنوانِ query بدهید و همان نقطه بالا بیاید.
- `/api/rag/ingest` هنوز روی Qdrant فعلی می‌نویسد. در بازهٔ کپی ingest متوقف شود، یا بعد از سوئیچ نقاطِ تازه دوباره همگام شوند.
- `QDRANT_URL` و `QDRANT_API_KEY` را فقط **بعد** از تأییدِ کپی عوض کنید؛ زودتر عوض شود، تیوتور جوابِ ناقص می‌دهد.
**باز:** محلِ سرورِ اصلیِ فعلیِ Qdrant (ادعای «روی سرورِ تیمِ دیگر» هنوز از روی سرور تأیید نشده). سرورِ جدید فعلاً به همان
`qdrant.tadrisino2.ir` وصل است.
## لاگ‌ها
هر درخواستِ `/api/` به‌صورتِ یک شیءِ JSON در هر خط لاگ می‌شود

View File

@@ -1,11 +1,12 @@
#!/bin/bash
# روی ۲۴۹، هر شب (zareian-applog-archive.timer). app.log را به logs/archive/ می‌برد و فشرده می‌کند.
# روی سرورِ اپ، هر شب (zareian-applog-archive.timer). app.log را به logs/archive/ می‌برد و فشرده می‌کند.
# برنامه با LOG_FILE_ROTATION=external از WatchedFileHandler استفاده می‌کند و پس از جابه‌جایی خودش app.log تازه
# می‌سازد؛ خطی که در همان لحظه نوشته شود در فایلِ جابه‌جاشده می‌ماند، گم نمی‌شود.
# این‌جا فقط ۳۰ روز می‌ماند (دیسکِ ۲۴۹ پر است) و فقط فایلی پاک می‌شود که ۲۴۷ گرفتنش را تأیید کرده
# (`.shipped`)؛ نسخه‌ی همیشگی روی ۲۴۷ است (pull-app-logs.sh). فقط زیرِ ~/mathapp-v2/logs می‌نویسد.
set -euo pipefail
LOGS=/home/administrator/mathapp-v2/logs
# پوشه‌ی پروژه از جای خودِ اسکریپت (ops/applogs/ → دو پوشه بالاتر)، تا روی هر سروری با هر کاربری درست باشد.
LOGS="$(cd "$(dirname "$0")/../.." && pwd)/logs"
ARCH="$LOGS/archive"
mkdir -p "$ARCH"
chmod 750 "$ARCH"

View File

@@ -0,0 +1,13 @@
# تدریسینو: سخت‌سازیِ SSH. فقط zareian (صاحبِ محصول) و mathapp-admin (حسابِ مشترکِ سینیور و صاحبِ شرکت) وارد می‌شوند.
# ورود با رمز هنوز باز است (تا رفتن به کلید SSH)؛ fail2ban جلوی حدس زدن را می‌گیرد.
PermitRootLogin no
AllowUsers zareian mathapp-admin
MaxAuthTries 4
MaxSessions 10
LoginGraceTime 2m
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no
PermitTunnel no
ClientAliveInterval 300
ClientAliveCountMax 2

View File

@@ -0,0 +1,18 @@
# تدریسینو: تنظیماتِ شبکه‌ی استاندارد (CIS). سرور مسیریاب نیست.
net.ipv4.tcp_syncookies = 1
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv6.conf.default.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
net.ipv4.conf.all.accept_source_route = 0
net.ipv6.conf.all.accept_source_route = 0
net.ipv4.conf.all.log_martians = 1
net.ipv4.icmp_echo_ignore_broadcasts = 1
kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1
fs.protected_hardlinks = 1
fs.protected_symlinks = 1

View File

@@ -0,0 +1,4 @@
# تدریسینو: آرشیوِ پیوسته‌ی WAL با pgBackRest (رمزنگاری‌شده). archive_timeout یعنی حداکثر ۶۰ ثانیه تغییرِ آرشیونشده.
archive_mode = on
archive_command = 'pgbackrest --stanza=zareian archive-push %p'
archive_timeout = 60

View File

@@ -0,0 +1,20 @@
[Unit]
Description=Gunicorn MathApp (tadrisino API)
After=network.target postgresql@16-zareian.service zareian-redis.service
Wants=postgresql@16-zareian.service zareian-redis.service
[Service]
User=mathapp
Group=www-data
WorkingDirectory=/home/mathapp/mathapp-v2
EnvironmentFile=/home/mathapp/mathapp-v2/.env
# /run/gunicorn بعد از هر ریبوت ساخته می‌شود (روی ۲۴۹ دستی ساخته شده بود و بعد از ریبوت نبود).
RuntimeDirectory=gunicorn
RuntimeDirectoryMode=0750
ExecStart=/home/mathapp/mathapp-v2/venv/bin/gunicorn --bind unix:/run/gunicorn/mathapp.sock config.wsgi:application
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
Alias=zareian-mathapp.service

View File

@@ -0,0 +1,17 @@
# روی ۲۱۶: /etc/pgbackrest.conf (root:postgres 640). کلیدِ واقعی فقط روی سرور و در /etc/tadrisino-backup/RECOVERY-KEYS است.
# تدریسینو: بکاپِ رمزنگاری‌شده. مخزنِ محلی /var/lib/pgbackrest؛ نسخه‌ی بیرونی با tadrisino-offsite-sync به ۲۴۷ (فقط فایلِ رمزشده).
[global]
repo1-path=/var/lib/pgbackrest
repo1-cipher-type=aes-256-cbc
repo1-cipher-pass=<RECOVERY-KEYS، خطِ کلیدِ ۱>
repo1-retention-full=2
compress-type=zst
process-max=2
start-fast=y
log-level-console=info
log-level-file=detail
[zareian]
pg1-path=/var/lib/postgresql/16/zareian
pg1-port=5440
pg1-socket-path=/var/run/postgresql

View File

@@ -0,0 +1,44 @@
#!/bin/bash
# تدریسینو: IPهای شناخته‌شده‌ی مخرب را پیش از اولین تلاش روی همه‌ی پورت‌ها می‌بندد.
# فهرست: FireHOL level1 (Spamhaus DROP/EDROP، DShield، Feodo، bogons)، روزی یک بار (tadrisino-blocklist.timer).
# شبکه‌های داخلی، رنجِ IPِ شرکت و رنج‌های ArvanCloud (از snippetِ nginx) هرگز بسته نمی‌شوند.
# اگر دانلود نشد، آخرین فهرستِ سالم (/var/lib/tadrisino-blocklist/list) دوباره بار می‌شود.
set -euo pipefail
URL=https://iplists.firehol.org/files/firehol_level1.netset
DIR=/var/lib/tadrisino-blocklist; SET=tadrisino-block
mkdir -p "$DIR"
tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT
if curl -fsS -m 90 "$URL" -o "$tmp" && [ "$(grep -c '^[0-9]' "$tmp")" -gt 1000 ]; then
python3 - "$tmp" "$DIR/list" <<'PY'
import ipaddress, re, sys
src, dst = sys.argv[1], sys.argv[2]
keep = ['127.0.0.0/8','10.0.0.0/8','172.16.0.0/12','192.168.0.0/16','100.64.0.0/10','169.254.0.0/16','78.38.35.0/24']
try:
keep += re.findall(r'^allow\s+([0-9./]+);', open('/etc/nginx/snippets/zareian-arvan-only.conf').read(), re.M)
except OSError:
pass
keep = [ipaddress.ip_network(k) for k in keep if ':' not in k]
out, dropped = [], 0
for line in open(src):
line = line.strip()
if not line or line[0] == '#':
continue
n = ipaddress.ip_network(line, strict=False)
if any(n.overlaps(k) for k in keep):
dropped += 1
continue
out.append(str(n))
open(dst, 'w').write('\n'.join(out) + '\n')
print(f'{len(out)} networks, {dropped} skipped (internal/company/Arvan)')
PY
else
echo "download failed; reloading last good list" >&2
fi
[ -s "$DIR/list" ] || { echo "no list available" >&2; exit 1; }
ipset create "$SET" hash:net maxelem 262144 -exist
ipset create "$SET-new" hash:net maxelem 262144 -exist
ipset flush "$SET-new"
sed "s/^/add $SET-new /" "$DIR/list" | ipset restore -exist
ipset swap "$SET-new" "$SET"; ipset destroy "$SET-new"
iptables -C INPUT -m set --match-set "$SET" src -j DROP 2>/dev/null || iptables -I INPUT 1 -m set --match-set "$SET" src -j DROP
echo "loaded $(ipset list "$SET" -t | sed -n 's/^Number of entries: //p') entries into $SET"

View File

@@ -0,0 +1,11 @@
[Unit]
Description=Tadrisino: block known-malicious IPs (FireHOL level1)
After=network-online.target ufw.service
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/tadrisino-blocklist
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Tadrisino: refresh malicious-IP blocklist daily
[Timer]
OnCalendar=*-*-* 05:20:00
RandomizedDelaySec=30m
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,19 @@
#!/bin/bash
# تدریسینو: دفترِ خرید (payment_paymentevent)، سفارش‌ها و تراکنش‌ها به CSV، هر ۶ ساعت. هر فایل پیش از نوشته‌شدن روی دیسک
# با age برای کلیدِ /etc/tadrisino-backup/age-recipient.txt رمز می‌شود؛ متنِ خوانا هیچ‌جا ذخیره نمی‌شود.
# بازکردن: age -d -i age-identity.txt payment-events-….csv.age (کلید در RECOVERY-KEYS). ۱۴ فایلِ آخرِ هر نوع می‌ماند.
set -euo pipefail
OUT=/var/lib/tadrisino-backup/payment-log
REC=/etc/tadrisino-backup/age-recipient.txt
stamp=$(date -u +%Y%m%dT%H%M%SZ)
q() {
psql -p 5440 -d zareian_mathapp -v ON_ERROR_STOP=1 -Atqc "\\copy ($1) to stdout csv header" | age -R "$REC" -o "$OUT/$2-$stamp.csv.age"
}
q "select id, created_at, order_id, user_id, provider, event, detail from payment_paymentevent order by id" payment-events
q "select id, created_at, user_id, plan_id, plan_title, amount, status, provider, gateway_authority, subscription_id from payment_order order by id" orders
q "select id, created_at, order_id, gateway, gateway_ref_id, status, paid_at from payment_transaction order by id" transactions
chmod 640 "$OUT"/*-"$stamp".csv.age
for kind in payment-events orders transactions; do
ls -1t "$OUT/$kind"-*.csv.age | tail -n +15 | xargs -r rm -f --
done
echo "wrote $OUT/*-$stamp.csv.age"

View File

@@ -0,0 +1,8 @@
[Unit]
Description=Tadrisino: encrypted payment CSVs every 6 hours
After=postgresql@16-zareian.service
[Service]
Type=oneshot
User=postgres
ExecStart=/usr/local/sbin/tadrisino-export-payment-events

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Tadrisino: encrypted payment CSVs every 6 hours
[Timer]
OnCalendar=*-*-* 01,07,13,19:15:00
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,11 @@
#!/bin/bash
# تدریسینو: نسخه‌ی بیرونیِ بکاپ. فقط فایل‌های رمزشده (مخزنِ pgBackRest با AES-256 و CSVهای age) به ۲۴۷ می‌روند.
# کلیدِ postgres روی ۲۴۷ با rrsync -wo محدود است: فقط نوشتن در ~/zareian-pgbackrest-216، نه خواندن، نه شِل.
# پس از موفقیتِ هر دو، زمانش در offsite-synced-at ثبت می‌شود و /api/health از روی آن عقب‌ماندگی را می‌سنجد.
set -euo pipefail
R=administrator@192.168.200.167
SSH="ssh -i /var/lib/postgresql/.ssh/id_offsite -o BatchMode=yes -o ConnectTimeout=20"
rsync -a --delete -e "$SSH" /var/lib/pgbackrest/ "$R:repo/"
rsync -a --delete -e "$SSH" /var/lib/tadrisino-backup/payment-log/ "$R:payment-log/"
date -u +%s > /var/lib/tadrisino-backup/offsite-synced-at.tmp
mv /var/lib/tadrisino-backup/offsite-synced-at.tmp /var/lib/tadrisino-backup/offsite-synced-at

View File

@@ -0,0 +1,8 @@
[Unit]
Description=Tadrisino: copy encrypted backups to 247
After=postgresql@16-zareian.service
[Service]
Type=oneshot
User=postgres
ExecStart=/usr/local/sbin/tadrisino-offsite-sync

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Tadrisino: copy encrypted backups to 247
[Timer]
OnCalendar=minutely
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,8 @@
[Unit]
Description=Tadrisino: daily differential encrypted backup
After=postgresql@16-zareian.service
[Service]
Type=oneshot
User=postgres
ExecStart=/usr/bin/pgbackrest --stanza=zareian --type=diff backup

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Tadrisino: daily differential encrypted backup
[Timer]
OnCalendar=*-*-* 03:40:00
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,8 @@
[Unit]
Description=Tadrisino: weekly full encrypted backup
After=postgresql@16-zareian.service
[Service]
Type=oneshot
User=postgres
ExecStart=/usr/bin/pgbackrest --stanza=zareian --type=full backup

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Tadrisino: weekly full encrypted backup
[Timer]
OnCalendar=Sun *-*-* 03:00:00
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,26 @@
#!/bin/bash
# تدریسینو: آزمونِ بازیابی از بکاپِ رمزشده (ماهی یک بار، دستی: sudo -u postgres tadrisino-restore-check).
# آخرین بکاپ + همه‌ی WALِ آرشیوشده را در پوشه‌ی موقت بازیابی می‌کند، Postgresِ موقت را روی سوکتِ همان پوشه (بی‌پورتِ شبکه،
# archive_mode=off تا چیزی به مخزن ننویسد) بالا می‌آورد، شمارش‌ها را کنارِ دیتابیسِ اصلی چاپ می‌کند و همه را پاک می‌کند.
set -euo pipefail
BIN=/usr/lib/postgresql/16/bin
work=$(mktemp -d /var/lib/postgresql/restore-check.XXXXXX)
trap '"$BIN/pg_ctl" -D "$work/data" -m immediate stop >/dev/null 2>&1 || true; rm -rf "$work"' EXIT
pgbackrest --stanza=zareian --pg1-path="$work/data" --type=default --archive-mode=off restore --log-level-console=warn
cat > "$work/data/postgresql.conf" <<CONF
listen_addresses = ''
port = 5499
unix_socket_directories = '$work'
archive_mode = off
shared_buffers = 128MB
CONF
echo 'local all all peer' > "$work/data/pg_hba.conf"
"$BIN/pg_ctl" -D "$work/data" -l "$work/log" -w -t 600 start >/dev/null
for _ in $(seq 120); do
[ "$(psql -h "$work" -p 5499 -d zareian_mathapp -Atc 'select pg_is_in_recovery()')" = f ] && break; sleep 1
done
Q="select 'users', count(*) from accounts_user union all select 'questions', count(*) from content_question
union all select 'orders', count(*) from payment_order union all select 'payment_events', count(*) from payment_paymentevent
union all select 'login_events', count(*) from accounts_loginevent"
echo "restored | live"
paste -d'|' <(psql -h "$work" -p 5499 -d zareian_mathapp -Atc "$Q") <(psql -p 5440 -d zareian_mathapp -Atc "$Q" | cut -d'|' -f2)

View File

@@ -0,0 +1,25 @@
# تدریسینو: SSH در برابرِ ربات‌های حدسِ رمز، پله‌ای تا آدمِ واقعی (اشتباهِ تایپی، کیبوردِ فارسی) گیر نکند.
# ۶ اشتباه در ۱۰ دقیقه → ۱۰ دقیقه بسته؛ هر بارِ تکرار دو برابر (۲۰، ۴۰، ...، حداکثر یک هفته).
# شبکه‌ی داخلیِ شرکت (ادمین‌ها، ۲۴۷، ۲۴۹) هرگز بسته نمی‌شود.
[DEFAULT]
ignoreip = 127.0.0.1/8 ::1 192.168.200.0/24
bantime.increment = true
bantime.factor = 2
bantime.maxtime = 1w
[sshd]
enabled = true
backend = systemd
mode = aggressive
maxretry = 6
findtime = 10m
bantime = 10m
# فقط رباتِ سمج: پنج بار بسته‌شدن در یک روز → یک هفته روی همه‌ی پورت‌ها.
[recidive]
enabled = true
logpath = /var/log/fail2ban.log
banaction = iptables-allports
findtime = 1d
maxretry = 5
bantime = 1w

View File

@@ -0,0 +1,13 @@
[Unit]
Description=Redis for zareian-mathapp only
After=network.target
[Service]
User=redis
Group=redis
ExecStart=/usr/bin/redis-server /etc/zareian-redis.conf
Restart=on-failure
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,5 @@
# تدریسینو، سرور اختصاصی: فقط localhost. max_connections با workers × DB_POOL_MAX_SIZE + 6 در /api/health سنجیده می‌شود.
listen_addresses = '127.0.0.1'
max_connections = 60
shared_buffers = 1GB
effective_cache_size = 4GB