diff --git a/deploy.sh b/deploy.sh index fc1d541..5d4d867 100755 --- a/deploy.sh +++ b/deploy.sh @@ -171,6 +171,24 @@ if 'LocMem' in settings.CACHES['default']['BACKEND'] and not settings.DEBUG: print(' ⚠ بدونِ Redis محدودیتِ نرخ بینِ workerها مشترک نیست (CACHE_URL).') PYCHECK +# ─── ۳.۶ مجوزِ فایل‌های داده ─────────────────────────────────────────────── +# این ماشین بینِ چند تیم مشترک است. تا ۲۰۲۶-۱۰-۰۴ دیتابیس و همه‌ی بکاپ‌ها 0644 بودند و +# پوشه‌ها قابلِ پیمایش؛ کاربرِ nobody روی سرور توانست سرِ db.sqlite3 و یک بکاپ را بخواند +# (شماره‌ها، refresh tokenهای بی‌هش، گفتگوها). در هر انتشار دوباره بسته می‌شود، چون بکاپِ +# دستیِ تازه دوباره با umaskِ پیش‌فرض (0644) ساخته می‌شود. nginx فقط به سوکتِ /run وصل +# می‌شود و از این پوشه چیزی نمی‌خواند (sites-enabled/mathapp، همان روز خوانده شد)؛ اگر +# سرویسِ دیگری این دیتابیس را مستقیم می‌خواند، از این به بعد خطای دسترسی می‌گیرد. +step "مجوزِ فایل‌های داده" +chmod o-rwx . || warn "مجوزِ پوشه‌ی پروژه عوض نشد." +for f in db.sqlite3 db.sqlite3-wal db.sqlite3-shm db.sqlite3-journal; do + if [ -e "$f" ]; then chmod 600 "$f"; fi +done +if [ -d .deploy-backups ]; then + chmod 700 .deploy-backups + find .deploy-backups -type f -exec chmod 600 {} + +fi +printf ' دیتابیس و بکاپ‌ها فقط برای %s خواندنی‌اند.\n' "$(id -un)" + # ─── ۴. دیتابیس ──────────────────────────────────────────────────────────── step "اعمالِ مهاجرت‌های دیتابیس" python manage.py migrate --noinput || fail "migrate شکست خورد؛ سرویس ری‌استارت نشد." diff --git a/docs/model-prices.md b/docs/model-prices.md index 049c6b3..e50718f 100644 --- a/docs/model-prices.md +++ b/docs/model-prices.md @@ -50,6 +50,7 @@ | ۲۰۲۶-۰۹-۲۶ | میانگینِ همه‌ی جواب‌های سنجش | `gpt-4.1-mini` | ۰٫۰۹۶ سنت | میانگینِ `costUsd`ِ ۵۴۱ فراخوانی (۱) | | ۲۰۲۶-۱۰-۰۱ | یک جوابِ دستیار، پرامپتِ `tutor-v11-math-only`، ۱۰۳ مسئله‌ی کتاب | `gpt-4.1-mini` | ۰٫۱۶۲ سنت (دبستان ۰٫۱۲۹، دبیرستان ۰٫۲۲۵) | توکنِ ورودی تخمینی (۰٫۳۸۷ توکن به ازای هر حرف)، خروجی از جوابِ واقعی، ضربدر قیمتِ بالا | | ۲۰۲۶-۱۰-۰۱ | یک سوالِ زنده در اپ (۱۲ سوال) | `gpt-4.1-mini` | ۰٫۲۲۵ سنت | فراخوانیِ واقعی | +| ۲۰۲۶-۱۰-۰۴ | یک جوابِ دستیار در لودتستِ سرورِ زنده (۵۰ جواب، استریم، سوال‌های تصادفیِ بانکِ سوالِ ده پایه) | `gpt-4.1-mini` | ۰٫۱۸۳ سنت (میانگین: ۳٬۱۸۱ توکنِ ورودی، ۳۴۸ خروجی) | `prompt_tokens`/`completion_tokens`ِ ذخیره‌شده روی پیام‌ها ضربدر قیمتِ بالا؛ داشبوردِ GapGPT خوانده نشد | عددِ برنامه‌ریزیِ قیمت‌گذاری **۰٫۲۰ سنت برای هر سوالِ دستیار** است و حدودِ ۷۰٪ِ آن ورودی است. هزینه‌ی embedding جدا اندازه گرفته نشده است. قیمتِ رسمیِ `text-embedding-3-small` بسیار کمتر از diff --git a/docs/security-before-production.md b/docs/security-before-production.md index 81213dd..c13c9d0 100644 --- a/docs/security-before-production.md +++ b/docs/security-before-production.md @@ -1,6 +1,7 @@ # پیش از پروداکشنِ واقعی: چیزهایی که عمداً باز مانده‌اند -**تاریخِ ثبت: ۲۰۲۶-۰۹-۲۵ (۳ مهر ۱۴۰۵)، کامیتِ پایه `66066cb`.** +**تاریخِ ثبت: ۲۰۲۶-۰۹-۲۵ (۳ مهر ۱۴۰۵)، کامیتِ پایه `66066cb`.** بازبینیِ دوباره با سرورِ زنده: +۲۰۲۶-۱۰-۰۴ (مواردِ ۱۹ تا ۲۳ و بازتولیدِ مورد ۸). این پروژه تا این تاریخ در حالتِ **دمو و ارائه‌ی کارآموزی** است، نه محصولِ نهایی. چند چیز عمداً باز گذاشته شده تا توسعه‌دهنده‌ها، فرانت‌کار و ارائه کار کنند. هر کدام برای یک سرورِ @@ -38,6 +39,11 @@ | ۱۶ | فرانت توکن را در `localStorage` نگه می‌دارد | کم | فرانت | | ۱۷ | ~~شماره‌ی موبایل نرمال نمی‌شود؛ سقف‌های per-phone با املای دیگر دور زده می‌شوند~~ **بسته شد ۲۰۲۶-۰۹-۲۵** | متوسط | کد | | ۱۸ | `/api/payment/orders/{id}/pay` بدونِ ورود و بدونِ سنجشِ مالکیت | کم | کد | +| ۱۹ | دیتابیس و بکاپ‌ها برای همه‌ی کاربرانِ ماشینِ مشترک خواندنی بودند | بالا | کد آماده (`deploy.sh`)؛ اعمال با دیپلوی | +| ۲۰ | یونیتِ systemd هنوز `DJANGO_DEBUG=1` و کلیدِ ناامن دارد؛ فقط یک خطِ `.env` جلوی DEBUG را گرفته | بالا (نهفته) | ادمینِ سرور | +| ۲۱ | `urllib3 2.7.0` و `python-dotenv 1.0.1` آسیب‌پذیریِ شناخته‌شده داشتند | کم | کد آماده؛ اعمال با دیپلوی | +| ۲۲ | سایتِ فرانت هیچ هدرِ امنیتی ندارد (CSP، frame-ancestors، HSTS) | کم | nginx/فرانت | +| ۲۳ | سقفِ دستیار برای مهمان‌ها بر اساسِ IP مشترک است؛ کلاسِ پشتِ یک IP بعد از ۲۰ سوال در ساعت ۴۲۹ می‌گیرد | متوسط (دسترس‌پذیری) | تصمیمِ محصولی | --- @@ -138,6 +144,12 @@ - **رفع (ادمینِ سرور):** پورتِ ۸۰/۴۴۳ فقط برای رنج‌های ArvanCloud، یا `set_real_ip_from` + `real_ip_header` در nginx. +- **بازتولید شد ۲۰۲۶-۱۰-۰۴:** `curl -H 'Host: mathapi.tadrisino2.ir' -H 'X-Forwarded-For: 1.2.3.4' + http://78.38.35.249/api/health` جواب داد `clientIp: 1.2.3.4`. یعنی هر سقفِ IP-محور (ورودِ ادمین + ۱۰ در ۱۵ دقیقه، درخواست و ورودِ OTP، ساختِ مهمان، دستیارِ مهمان) با عوض کردنِ این هدر در هر + درخواست دور زده می‌شود، و حفاظتِ DDoS/WAFِ آروان هم برای این مسیر وجود ندارد. سقف‌های + per-phone و per-code که در دیتابیس شمرده می‌شوند متأثر نیستند. پورت‌های ۸۰ و ۴۴۳ هر دو باز بودند. + ## ۹. نبودِ کشِ مشترک (`CACHE_URL`) - **چیست:** بدونِ `CACHE_URL` هر worker کشِ `LocMemCache` خودش را دارد (۳ worker). @@ -204,6 +216,8 @@ `docker` و `lxd`، روی ماشینی که Gitea و ایمیلِ شرکت هم رویش است. رمز یک بار در گفتگو با agent گذاشته شده و روی دسکتاپِ صاحبِ پروژه در یک فایلِ متنی نگه داشته می‌شود. - **خطر:** هر نشتِ رمز یعنی دسترسیِ root به کلِ ماشینِ شرکت، نه فقط این پروژه. + ۲۰۲۶-۱۰-۰۴: `PasswordAuthentication yes` در `sshd_config` و پورتِ ۲۲ از اینترنت باز است؛ پس رمز + از هر جای اینترنت حدس‌زدنی است (fail2ban سنجیده نشد). - **رفع (ادمینِ سرور):** کلیدِ SSH به‌جای رمز، تعویضِ رمز، حسابِ جدا برای این پروژه با دسترسیِ فقط به `~/mathapp-v2` و یک خطِ `NOPASSWD` فقط برای `systemctl restart zareian-mathapp`؛ پاک کردنِ فایلِ رمز از دسکتاپ (Keychain جایگزین است). @@ -252,6 +266,63 @@ - **رفع:** در `paymentUrl` یک توکنِ امضاشده‌ی کوتاه‌عمر (`signing`) با `order_id` بگذارید و همان‌جا بسنجید؛ یا شناسه‌ی غیرترتیبی. +## ۱۹. دیتابیس و بکاپ‌ها برای همه‌ی کاربرانِ ماشین خواندنی بودند (۲۰۲۶-۱۰-۰۴) + +- **چیست:** `/home/administrator` با مجوزِ 755، `~/mathapp-v2` و `.deploy-backups` با 775، و + `db.sqlite3` و هر ۳۵ بکاپ با 644. `.env` درست 600 بود. +- **بازتولید شد:** `sudo -u nobody head -c 15 ~/mathapp-v2/db.sqlite3` و همین برای یک بکاپ، هر دو + `SQLite format 3` چاپ کردند. هر حسابِ دیگرِ این ماشین (aria، artina، behdad، zahra، git، gitea، + metabase) یا هر سرویسِ نفوذشده‌ی دیگری شماره‌ها، گفتگوهای کودکان و refresh tokenهای بی‌هش (مورد ۱۱، + یعنی ورود به‌جای هر کاربر) را می‌خواند. +- **رفع در کد:** مرحله‌ی «مجوزِ فایل‌های داده» در `deploy.sh`: پوشه‌ی پروژه بی‌دسترسیِ others، + دیتابیس 600، پوشه‌ی بکاپ 700 و فایل‌هایش 600، در هر انتشار (بکاپِ دستیِ تازه دوباره 644 ساخته + می‌شود). خودِ `/home/administrator` دست نخورد چون nginx فرانت را از `~/mathapp_frontend_new/dist` + می‌خواند. بعد از دیپلوی باید دوباره با `sudo -u nobody` سنجیده شود. هش کردنِ refresh token (مورد ۱۱) + هنوز باز است. + +## ۲۰. یونیتِ systemd: DEBUG و کلیدِ ناامن (۲۰۲۶-۱۰-۰۴) + +- **چیست:** `systemctl cat gunicorn-mathapp` هنوز `Environment="DJANGO_DEBUG=1"` و + `Environment="DJANGO_SECRET_KEY=django-insecure-…"` دارد. `EnvironmentFile=.env` بعد از آن می‌آید و + `.env` خطِ `DJANGO_DEBUG=0` دارد (شمرده شد، مقدار چاپ نشد)؛ برای همین الان DEBUG خاموش است. +- **خطر:** اگر روزی آن یک خط از `.env` پاک شود (مثلاً با این فکر که «پیش‌فرضِ DEBUG خاموش است»)، + بعد از ری‌استارت DEBUG روشن می‌شود و کدِ `0000` برای هر شماره‌ای پذیرفته می‌شود. `deploy.sh` این را + نمی‌بیند، چون تنظیماتِ مؤثر را با محیطِ شِل می‌خواند نه محیطِ یونیت. +- **رفع (ادمینِ سرور، با بکاپِ یونیت):** هر دو خطِ `Environment=` را از یونیت بردارید، `daemon-reload`، + ری‌استارت. حذفِ خطِ کلید به‌تنهایی کسی را بیرون نمی‌اندازد اگر `.env` همان کلید را داشته باشد؛ + عوض کردنِ خودِ کلید (مورد ۱) جداست. + +## ۲۱. کتابخانه‌های آسیب‌پذیر (۲۰۲۶-۱۰-۰۴) + +- **چیست:** پایگاهِ OSV برای ۴۸ بسته‌ی نصب‌شده: `urllib3 2.7.0` (GHSA-8988-9cw3-xx77 و + GHSA-vxq7-64xx-v4gw با شدتِ بالا، GHSA-gh4c-6fx4-qh6g متوسط) و `python-dotenv 1.0.1` + (GHSA-mf9w-mj56-hr94، فقط در `set_key` که پروژه صدا نمی‌زند). اثرِ عملی کم است: سناریوی هر سه‌ی + urllib3 پاسخِ مخربِ سرورِ بالادستی یا پراکسی است (بازار، زرین‌پال، کاوه‌نگار، Qdrant). `npm audit` روی + وابستگی‌های اجراییِ فرانت صفر. +- **رفع در کد:** `python-dotenv==1.2.2`، `urllib3>=2.8.0`، و `gunicorn==26.2.0` (تا امروز فقط دستی + روی سرور نصب بود). هر سه با پایتونِ ۳.۱۰ِ سرور سازگارند و سرور به PyPI دسترسی دارد (سنجیده شد). + +## ۲۲. هدرهای امنیتیِ فرانت (۲۰۲۶-۱۰-۰۴) + +- **چیست:** `https://math.tadrisino2.ir/` فقط `x-xss-protection` دارد؛ نه CSP، نه `X-Frame-Options`/ + `frame-ancestors`، نه HSTS، نه `X-Content-Type-Options`. API همه‌ی این‌ها را جز HSTS و CSP دارد. +- **خطر:** صفحه‌ی اپ را می‌شود در iframeِ سایتِ دیگری گذاشت (clickjacking)؛ و چون توکن در + `localStorage` است (مورد ۱۶)، نبودِ CSP اثرِ هر XSSِ احتمالی را کامل می‌کند. در خودِ فرانت سینکِ XSS + پیدا نشد (تنها `dangerouslySetInnerHTML` روی SVGِ ثابتِ شکلک‌هاست). +- **رفع:** در nginxِ `mathapp-front` یا در آروان: `Content-Security-Policy` با `frame-ancestors 'none'` + و `connect-src` به دامنه‌ی API، و `X-Content-Type-Options: nosniff`. + +## ۲۳. سقفِ دستیار برای مهمان‌ها روی IPِ مشترک (۲۰۲۶-۱۰-۰۴) + +- **چیست:** `GuestAwareAIThrottle` برای مهمان‌ها سطلِ `ai_ask` (۲۰ در ساعت) را بر اساسِ IP می‌سازد تا + ساختنِ مهمانِ تازه سقف را دور نزند. +- **بازتولید شد:** ۲۲ مهمانِ متفاوت از یک IP، هر کدام اولین سوالش: ۲۰ تا ۲۰۰، ۲۱اُم و ۲۲اُم ۴۲۹. + دانش‌آموزانِ مهمانِ یک کلاس پشتِ وای‌فایِ مدرسه یا CGNAT با هم ساعتی ۲۰ سوال دارند (روی سرور با ۳ + workerِ بی‌Redis تا حدودِ ۶۰). +- **چرا تصمیمِ محصولی است:** سقفِ IP جلوی «مهمان بساز، سه سوالِ رایگان بگیر» را می‌گیرد. از + ۲۰۲۶-۱۰-۰۳ سقفِ سراسریِ `AI_GLOBAL_DAILY_MAX` (۳۰۰۰ در روز، حدودِ ۶ دلار) هزینه را جدا محدود + می‌کند، پس می‌شود سطل را per-user کرد و فقط ساختِ مهمان را محدود نگه داشت. تا تصمیم، دست نخورد. + --- ## چیزهایی که بسته شده‌اند (برای اینکه دوباره باز نشوند) diff --git a/docs/technical-handbook.md b/docs/technical-handbook.md index 19467df..d197ae6 100644 --- a/docs/technical-handbook.md +++ b/docs/technical-handbook.md @@ -932,7 +932,7 @@ fetch ← `npm ci` ← بررسیِ نوع‌ها ← build در `dist.next` ← ### ۱۴-۳ مقیاسِ اجرا: کجا گلوگاه است و بعد چه -وضعِ فعلی: ۲۴ درخواستِ هم‌زمان؛ درخواستِ عادی چند ده میلی‌ثانیه، جوابِ دستیار ده‌ها ثانیه. توکن stateless است، پس هر تعداد نسخه‌ی دیگر از بک‌اند پشتِ nginx بی‌تغییرِ کد کار می‌کند — به شرطِ دیتابیس و کشِ مشترک. +وضعِ فعلی: ۳ worker × ۳۲ نخ = ۹۶ درخواستِ هم‌زمان (تا ۲۰۲۶-۱۰-۰۴ ۸ نخ بود)؛ درخواستِ عادی چند ده میلی‌ثانیه، جوابِ دستیار روی سرور میانه‌ی ۵ تا ۸ ثانیه و گاهی ۴۰ ثانیه، که تمامش یک نخ را نگه می‌دارد. لودتستِ همان روز: با ۸ نخ از حدودِ ۲۰ پرسشِ هم‌زمان صفحه‌های عادی چند ثانیه صف کشیدند؛ با ۳۲ نخ تا ۷۵ پرسش نه. توکن stateless است، پس هر تعداد نسخه‌ی دیگر از بک‌اند پشتِ nginx بی‌تغییرِ کد کار می‌کند — به شرطِ دیتابیس و کشِ مشترک. | # | گلوگاه | راه‌حلِ ساده‌ی بعدی | نیاز | |---|---|---|---| diff --git a/gunicorn.conf.py b/gunicorn.conf.py index da28770..d52c837 100644 --- a/gunicorn.conf.py +++ b/gunicorn.conf.py @@ -14,10 +14,18 @@ gthread هر worker را چند نخی می‌کند: ۳ worker × ۸ نخ = ۲ کدِ جنگو برای نخ امن است و SQLite با `OPTIONS.timeout=20` نوشتنِ هم‌زمان را تحمل می‌کند. timeout در gthread به ضربانِ خودِ worker است، نه طولِ هر درخواست، پس استریمِ طولانی دیگر کشته نمی‌شود. + +۸ → ۳۲ نخ (۲۰۲۶-۱۰-۰۴، لودتست): هر جوابِ دستیار یک نخ را تا آخرِ جواب نگه می‌دارد +(روی سرور میانه ۵ و p90 ۱۳ ثانیه). با ۳×۸ از حدودِ ۲۰ پرسشِ هم‌زمان، صفحه‌های عادیِ اپ +پشتِ استریم‌ها صف کشیدند (تا ۲۳ ثانیه در ۵۰ پرسش؛ فرانت بعد از ۱۵ ثانیه خطا می‌دهد)، چون +gthread اتصال را حتی وقتی همه‌ی نخ‌هایش مشغول‌اند می‌پذیرد و workerها یکنواخت پر نمی‌شوند. +با ۳×۳۲ تا ۷۵ پرسشِ هم‌زمان صفحه‌ها زیرِ ۱۱۰ میلی‌ثانیه ماندند. این روی لپ‌تاپ با مدل و +Qdrantِ شبیه‌سازی‌شده سنجیده شد؛ CPUِ مشترکِ سرور و رفتارِ GapGPT زیرِ این بار جدا +سنجیده نشده‌اند. نخِ منتظرِ شبکه تقریباً هزینه ندارد؛ هر نخ اتصالِ SQLiteِ خودش را دارد. """ worker_class = 'gthread' -threads = 8 +threads = 32 timeout = 120 graceful_timeout = 30 keepalive = 5 diff --git a/requirements.txt b/requirements.txt index 8ab8c80..55befd7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,8 +4,14 @@ drf-spectacular==0.30.0 django-cors-headers==4.9.0 requests==2.33.1 redis==5.2.1 -python-dotenv==1.0.1 +python-dotenv==1.2.2 whitenoise==6.11.0 +# همان نسخه‌ای که سرور با آن اجرا می‌شود (یونیتِ systemd آن را از venv صدا می‌زند). تا +# ۲۰۲۶-۱۰-۰۴ در این فایل نبود و فقط دستی روی سرور نصب شده بود. +gunicorn==26.2.0 +# وابستگیِ غیرمستقیمِ requests و qdrant-client. ۲.۷.۰ سه آسیب‌پذیریِ شناخته‌شده داشت +# (GHSA-8988-9cw3-xx77، GHSA-vxq7-64xx-v4gw، GHSA-gh4c-6fx4-qh6g؛ OSV، ۲۰۲۶-۱۰-۰۴). +urllib3>=2.8.0 # RAG / AI tutor (اپ rag). کلاینت‌ها lazy import می‌شن؛ نبودِ این‌ها فقط # مسیرِ RAG رو از کار می‌ندازه، نه بقیه‌ی پروژه رو.