- ops/nginx/: the two nginx sites as they now run on 249. Only ArvanCloud edges reach the app (direct hits on the IP get 403, so Arvan's DDoS/WAF can't be skipped); the frontend gets CSP (own sources only, the one inline script by hash), X-Frame-Options, nosniff, Referrer-Policy, Permissions-Policy, HSTS; API body limit 100M -> 20M plus HSTS. Checked from outside and in Chrome (all tabs and an island quiz, logged in): no CSP errors. - Demo accounts (users 11-15, 25) and TEST_LOGIN_PHONES removed from the server; the health warning for fixed-code logins now has a test and says how to test instead (an OTPCode made in the admin). - SMS daily cap on the server is 2000 (launch of ~600 users). - CLAUDE.md: never run tests with --parallel on macOS, always --noinput (both hang forever without an error). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
398 lines
19 KiB
Python
398 lines
19 KiB
Python
"""
|
||
GET /api/health — «آیا این سرور واقعاً سالم است؟»
|
||
|
||
چرا لازم بود: تا امروز تنها راهِ فهمیدنِ اینکه دیپلوی درست نشسته یا نه، این
|
||
بود که کسی از بیرون اپ را دستی امتحان کند و اگر چیزی غلط بود به آدمی که به
|
||
سرور دسترسی دارد پیام بدهد. یعنی خرابی را همیشه آدم کشف میکرد، نه ابزار.
|
||
این اندپوینت همان بررسیها را از بیرون و بدونِ دسترسیِ SSH ممکن میکند.
|
||
|
||
مهمترین بررسی «مهاجرتهای اعمالنشده» است: شایعترین حالتِ خرابِ دیپلوی این
|
||
است که کد pull شده ولی migrate اجرا نشده — اپ بالا میآید و بعد زیرِ بارِ
|
||
واقعی با خطای ستونِ ناموجود میشکند.
|
||
|
||
عمداً هیچ سکرتی برنمیگرداند: فقط بله/خیرِ «تنظیم شده» و شمارشِ محتوا.
|
||
"""
|
||
import logging
|
||
import os
|
||
from datetime import timedelta
|
||
|
||
from django.conf import settings
|
||
from django.core.cache import cache
|
||
from django.db import connection
|
||
from django.db.migrations.executor import MigrationExecutor
|
||
from drf_spectacular.utils import extend_schema
|
||
from rest_framework.permissions import AllowAny
|
||
from rest_framework.response import Response
|
||
from rest_framework.throttling import ScopedRateThrottle
|
||
from rest_framework.views import APIView
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _check_database() -> dict:
|
||
try:
|
||
with connection.cursor() as cursor:
|
||
cursor.execute('SELECT 1')
|
||
cursor.fetchone()
|
||
return {'ok': True, 'engine': connection.vendor}
|
||
except Exception as exc: # noqa: BLE001
|
||
return {'ok': False, 'error': type(exc).__name__}
|
||
|
||
|
||
def _check_migrations() -> dict:
|
||
"""مهاجرتِ اعمالنشده یعنی دیپلوی نیمهکاره است."""
|
||
try:
|
||
executor = MigrationExecutor(connection)
|
||
targets = executor.loader.graph.leaf_nodes()
|
||
pending = executor.migration_plan(targets)
|
||
# migration_plan یک فهرست از (migration, backwards) میدهد؛ خودِ
|
||
# Migration فیلدهای app_label و name دارد (نه key).
|
||
return {
|
||
'ok': not pending,
|
||
'pending': [f'{migration.app_label}.{migration.name}' for migration, _ in pending],
|
||
}
|
||
except Exception as exc: # noqa: BLE001
|
||
return {'ok': False, 'error': type(exc).__name__}
|
||
|
||
|
||
def _check_cache() -> dict:
|
||
"""با DEBUG=False کشِ مشترک اجباری است؛ throttle بدونش بیاثر میشود."""
|
||
try:
|
||
cache.set('health:probe', 'ok', 10)
|
||
return {'ok': cache.get('health:probe') == 'ok', 'backend': settings.CACHES['default']['BACKEND'].rsplit('.', 1)[-1]}
|
||
except Exception as exc: # noqa: BLE001
|
||
return {'ok': False, 'error': type(exc).__name__}
|
||
|
||
|
||
def _check_content() -> dict:
|
||
"""
|
||
محتوا روی این سرور هست یا نه — بدونِ این، اپ سالم به نظر میرسد ولی
|
||
کاربر صفحهی خالی میبیند.
|
||
"""
|
||
try:
|
||
from content.models import Grade, Lesson, Question
|
||
|
||
grades = Grade.objects.count()
|
||
lessons = Lesson.objects.count()
|
||
questions = Question.objects.count()
|
||
return {
|
||
'ok': grades > 0 and lessons > 0,
|
||
'grades': grades,
|
||
'lessons': lessons,
|
||
'questions': questions,
|
||
}
|
||
except Exception as exc: # noqa: BLE001
|
||
return {'ok': False, 'error': type(exc).__name__}
|
||
|
||
|
||
def _traffic() -> dict:
|
||
"""
|
||
آیا کسی واقعاً به این سرور وصل میشود؟
|
||
|
||
تا امروز راهی نبود که از بیرون بفهمیم فرانت به سرور رسیده یا نه — باید
|
||
منتظر میماندیم کسی بگوید. این سه عدد همان سیگنالاند. فقط شمارش و زمان،
|
||
بدونِ هیچ اطلاعاتِ شخصی.
|
||
"""
|
||
try:
|
||
from django.utils import timezone
|
||
|
||
from accounts.models import User
|
||
|
||
since = timezone.now() - timedelta(hours=24)
|
||
last = User.objects.order_by('-last_activity_at').values_list(
|
||
'last_activity_at', flat=True,
|
||
).first()
|
||
return {
|
||
'users': User.objects.count(),
|
||
'activeLast24h': User.objects.filter(last_activity_at__gte=since).count(),
|
||
'lastActivityAt': last.isoformat() if last else None,
|
||
}
|
||
except Exception as exc: # noqa: BLE001
|
||
return {'error': type(exc).__name__}
|
||
|
||
|
||
def _check_integrations() -> dict:
|
||
"""
|
||
فقط «تنظیم شده یا نه» — هیچ کلیدی برنمیگردد. `aiProvider` وضعیتِ واقعیِ
|
||
آخرین تماس است: کلیدِ تنظیمشده با اعتبارِ تمامشده هم `openai: true` بود.
|
||
"""
|
||
return {
|
||
'qdrant': bool(getattr(settings, 'QDRANT_API_KEY', '')),
|
||
'openai': bool(getattr(settings, 'OPENAI_API_KEY', '')),
|
||
'sms': bool(getattr(settings, 'KAVENEGAR_API_KEY', '')),
|
||
'bazaar': _bazaar_configured(),
|
||
'bazaarDynamicPrice': bool(getattr(settings, 'BAZAAR_DYNAMIC_PRICE_KEY', '')),
|
||
'aiProvider': _ai_provider_state(),
|
||
'chatModel': getattr(settings, 'OPENAI_CHAT_MODEL', ''),
|
||
'chatModelUpper': getattr(settings, 'OPENAI_CHAT_MODEL_UPPER', '') or None,
|
||
}
|
||
|
||
|
||
def _bazaar_configured() -> bool:
|
||
try:
|
||
from payment.bazaar import is_configured
|
||
return is_configured()
|
||
except Exception: # noqa: BLE001 — سلامت نباید با خطای خواندنِ اعتبارِ بازار ۵۰۰ بدهد؛ ولی دیده شود
|
||
logger.exception('health: بررسیِ تنظیمِ بازار شکست خورد')
|
||
return False
|
||
|
||
|
||
def _ai_provider_state() -> dict:
|
||
try:
|
||
from statuslog.models import StatusChangeLog
|
||
|
||
row = StatusChangeLog.objects.filter(label='ai_tutor.Provider').values(
|
||
'new_status', 'created_at').first()
|
||
except Exception as exc: # noqa: BLE001
|
||
return {'error': type(exc).__name__}
|
||
if row is None:
|
||
return {'state': 'ok', 'since': None}
|
||
return {'state': row['new_status'], 'since': row['created_at'].isoformat()}
|
||
|
||
|
||
def _offsite_backup() -> dict:
|
||
"""
|
||
کپیِ زندهی دیتابیس روی سرورِ دیگر (`ops/pgbackup/`، شرح در docs/operations.md «بکاپ»).
|
||
|
||
آن سرور با `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)
|
||
|
||
|
||
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}
|
||
|
||
|
||
# اتصالهایی بیرون از poolِ workerها: pg_receivewal و pg_basebackupِ سرورِ بکاپ (۲)، pg_dump و
|
||
# migrateِ deploy.sh (۲)، فرمانِ مدیریتی یا psqlِ دستی (۲)، و workerی که gunicorn با max_requests
|
||
# عوض میکند و تا بسته شدنِ قبلی همزمان بالاست (یک pool).
|
||
DB_CONNECTION_RESERVE = 6
|
||
|
||
|
||
def _db_pool() -> dict:
|
||
"""
|
||
poolِ اتصالِ همین worker (config/settings.py) و بودجهی کلِ اتصالها در برابرِ max_connections.
|
||
|
||
آمارِ pool مالِ workerی است که این درخواست را جواب داد، نه همه؛ `requestsErrors` یعنی چند بار
|
||
درخواستی تا DB_POOL_TIMEOUT اتصال نگرفت و ۵۰۳ شد (از بالا آمدنِ این worker). بودجه از
|
||
GUNICORN_WORKERS (gunicorn.conf.py، post_fork) حساب میشود؛ بیرون از gunicorn (runserver) ۱ است.
|
||
"""
|
||
if connection.vendor != 'postgresql':
|
||
return {'state': 'not_postgres'}
|
||
pool = connection.pool
|
||
if pool is None:
|
||
return {'state': 'off'}
|
||
try:
|
||
with connection.cursor() as cursor:
|
||
cursor.execute(
|
||
"SELECT current_setting('max_connections')::int - current_setting('superuser_reserved_connections')::int, "
|
||
'(SELECT count(*) FROM pg_stat_activity WHERE datname = current_database())'
|
||
)
|
||
allowed, in_use = cursor.fetchone()
|
||
except Exception as exc: # noqa: BLE001
|
||
return {'state': 'error', 'error': type(exc).__name__}
|
||
stats = pool.get_stats()
|
||
workers = int(os.environ.get('GUNICORN_WORKERS', '1'))
|
||
budget = workers * pool.max_size + DB_CONNECTION_RESERVE
|
||
return {
|
||
'state': 'ok' if budget <= allowed else 'over_budget',
|
||
'workers': workers,
|
||
'poolMaxSize': pool.max_size,
|
||
'budget': budget,
|
||
'maxConnections': allowed,
|
||
'connectionsInUse': in_use,
|
||
'thisWorker': {
|
||
'size': stats.get('pool_size', 0),
|
||
'available': stats.get('pool_available', 0),
|
||
'waiting': stats.get('requests_waiting', 0),
|
||
'requests': stats.get('requests_num', 0),
|
||
'requestsQueued': stats.get('requests_queued', 0),
|
||
'requestsErrors': stats.get('requests_errors', 0),
|
||
},
|
||
}
|
||
|
||
|
||
def _ai_concurrency() -> dict:
|
||
from ai_tutor import concurrency
|
||
|
||
return {'maxPerWorker': concurrency.limit(), 'inFlightThisWorker': concurrency.in_flight()}
|
||
|
||
|
||
def _db_pool_warnings(pool: dict) -> list[str]:
|
||
if pool.get('state') == 'over_budget':
|
||
return [
|
||
f"بودجهی اتصالِ دیتابیس ({pool['workers']} worker × {pool['poolMaxSize']} + {DB_CONNECTION_RESERVE} رزرو "
|
||
f"= {pool['budget']}) از سقفِ Postgres ({pool['maxConnections']}) بیشتر است: زیرِ بار درخواستها ۵۰۰ میگیرند. "
|
||
'DB_POOL_MAX_SIZE یا تعدادِ workerها را کم کنید.'
|
||
]
|
||
if pool.get('state') == 'error':
|
||
return ['وضعیتِ poolِ دیتابیس خوانده نشد.']
|
||
return []
|
||
|
||
|
||
_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 []
|
||
|
||
|
||
# بیش از این یعنی آرشیوِ شبانه (`ops/applogs/archive-app-log.sh`) چند روز است اجرا نشده؛
|
||
# با ترافیکِ ۶۰۰ کاربرِ فعال یک روز حدودِ ۳۰ مگ است (برآورد، نه سنجش).
|
||
APP_LOG_MAX_BYTES = 256 * 2**20
|
||
|
||
|
||
def _app_log_warnings() -> list[str]:
|
||
"""با چرخشِ بیرونی، برنامه app.log را پاک نمیکند؛ اگر آرشیوِ شبانه بخوابد فایل بیانتها بزرگ میشود."""
|
||
if not settings.LOG_DIR or settings.LOG_FILE_ROTATION != 'external':
|
||
return []
|
||
try:
|
||
size = os.path.getsize(os.path.join(settings.LOG_DIR, 'app.log'))
|
||
except FileNotFoundError:
|
||
return []
|
||
if size > APP_LOG_MAX_BYTES:
|
||
return [f'app.log {size // 2**20} مگ است: آرشیوِ شبانهی لاگ (zareian-applog-archive.timer) اجرا نشده.']
|
||
return []
|
||
|
||
|
||
def _warnings() -> list[str]:
|
||
"""
|
||
چیزهایی که خرابی نیستند ولی روی یک سرورِ عمومی خطرناکاند.
|
||
|
||
اینجا هستند چون تنها راهِ دیدنشان تا امروز این بود که کسی تنظیماتِ سرور را
|
||
از نزدیک بخواند — و ما به سرور دسترسی نداریم.
|
||
"""
|
||
out = []
|
||
if settings.DEBUG:
|
||
out.append(
|
||
'DEBUG روشن است: صفحههای خطا کلِ تنظیمات و traceback را نشان میدهند، '
|
||
'و کدِ ورودِ آزمایشیِ 0000 برای هر شمارهای پذیرفته میشود. '
|
||
'روی سرورِ عمومی باید خاموش باشد.'
|
||
)
|
||
if getattr(settings, 'DEV_MASTER_OTP', None) is not None:
|
||
out.append('کدِ یکسانِ ورود (DEV_MASTER_OTP) فعال است — هر کسی میتواند بهجای هر کاربری وارد شود.')
|
||
test_phones = getattr(settings, 'TEST_LOGIN_PHONES', {})
|
||
if test_phones:
|
||
# نه خودِ شماره و نه کد برنمیگردد؛ فقط تعداد، تا کسی که تنظیماتِ سرور
|
||
# را ندیده هم بداند چند حسابِ تست با کدِ ثابت باز است و بتواند ببنددشان.
|
||
# تعداد عمداً گفته نمیشود: این صفحه عمومی است و «۷ حسابِ کدِ ثابت» برای
|
||
# مهاجم یعنی هدفِ مشخص برای حدس زدن.
|
||
out.append(
|
||
'شمارههای تستِ ورود با کدِ ثابت فعالاند (TEST_LOGIN_PHONES). روی سرور باید خالی باشد: '
|
||
'هر کس کد را دیده باشد وارد میشود. برای آزمون یک OTPCode در پنلِ ادمین بسازید.'
|
||
)
|
||
if not settings.DEBUG and settings.SECRET_KEY.startswith('django-insecure'):
|
||
# بزرگترین ریسکِ باز (docs/security-before-production.md): این کلید در گیت است و هر
|
||
# کس ریپو را ببیند برای هر کاربری توکن میسازد. فقط بله/خیر، نه خودِ کلید.
|
||
out.append(
|
||
'کلیدِ امضای توکن همان کلیدِ کامیتشده در ریپو است (django-insecure): هر کس ریپو را '
|
||
'ببیند میتواند بهجای هر کاربری وارد شود. راهِ رفع در docs/security-before-production.md.'
|
||
)
|
||
if _ai_provider_state().get('state') == 'quota_exhausted':
|
||
out.append(
|
||
'اعتبارِ سرویسِ هوش مصنوعی (GapGPT) تمام شده: دستیار برای همهی کاربرها '
|
||
'«در دسترس نیست» میدهد تا حساب شارژ شود.'
|
||
)
|
||
out.extend(_app_log_warnings())
|
||
backend = settings.CACHES['default']['BACKEND']
|
||
if not settings.DEBUG and 'LocMem' in backend:
|
||
out.append('کش محلیِ هر پروسه است؛ محدودیتِ نرخ بینِ workerها مشترک نیست.')
|
||
if not getattr(settings, 'CORS_ALLOWED_ORIGINS', None):
|
||
out.append('هیچ origin ای برای CORS مجاز نیست — مرورگر همهی درخواستهای فرانت را بلاک میکند.')
|
||
return out
|
||
|
||
|
||
from payment.permissions import HasInternalServiceKey # noqa: E402
|
||
|
||
|
||
class HealthView(APIView):
|
||
"""
|
||
GET /api/health — وضعیتِ این نسخهی مستقرشده.
|
||
|
||
۲۰۰ یعنی سالم، ۵۰۳ یعنی چیزی واقعاً خراب است (دیتابیس، کش، یا مهاجرتِ
|
||
اعمالنشده). نبودِ محتوا خرابی حساب نمیشود ولی گزارش میشود.
|
||
"""
|
||
|
||
permission_classes = [AllowAny]
|
||
authentication_classes = []
|
||
throttle_classes = [ScopedRateThrottle]
|
||
throttle_scope = 'health'
|
||
|
||
@extend_schema(tags=['System'], responses={200: dict, 503: dict})
|
||
def get(self, request):
|
||
checks = {
|
||
'database': _check_database(),
|
||
'migrations': _check_migrations(),
|
||
'cache': _check_cache(),
|
||
'content': _check_content(),
|
||
}
|
||
critical_ok = all(checks[k]['ok'] for k in ('database', 'migrations', 'cache'))
|
||
|
||
status = 200 if critical_ok else 503
|
||
# عموم فقط ok/error میبینند؛ جزئیات (هشدارها، ترافیک، سرویسهای وصلشده، IP) پشتِ هدرِ
|
||
# X-Internal-Key. کد ۲۰۰/۵۰۳ عمومی میماند تا پایشِ بیرونی کار کند. اگر INTERNAL_SERVICE_KEY
|
||
# در .env نباشد، هیچکس جزئیات را نمیبیند (نه اینکه همه ببینند).
|
||
if not HasInternalServiceKey().has_permission(request, self):
|
||
return Response({'status': 'ok' if critical_ok else 'error'}, status=status)
|
||
|
||
offsite = _offsite_backup()
|
||
db_pool = _db_pool()
|
||
warnings = _warnings() + _offsite_warnings(offsite) + _db_pool_warnings(db_pool)
|
||
payload = {
|
||
'status': 'ok' if critical_ok else 'error',
|
||
'debug': bool(settings.DEBUG),
|
||
'warnings': warnings,
|
||
'checks': checks,
|
||
'integrations': _check_integrations(),
|
||
'traffic': _traffic(),
|
||
'offsiteBackup': offsite,
|
||
'dbPool': db_pool,
|
||
# جوابهای همزمانِ دستیار در workerی که این را جواب داد (ai_tutor/concurrency.py).
|
||
'aiConcurrency': _ai_concurrency(),
|
||
'allowedOrigins': list(getattr(settings, 'CORS_ALLOWED_ORIGINS', [])),
|
||
# IPای که throttle برای همین درخواست میبیند. باید IPِ عمومیِ خودِ
|
||
# صدازننده باشد؛ اگر IPِ ArvanCloud یا nginx بود، NUM_PROXIES غلط
|
||
# است و همهی مهمانها یک سهمِ مشترک دارند. فقط IPِ خودِ صدازننده
|
||
# را به خودش برمیگرداند، پس چیزی لو نمیدهد.
|
||
'clientIp': ScopedRateThrottle().get_ident(request),
|
||
}
|
||
return Response(payload, status=status)
|