diff --git a/docs/31-siro-operations-runbook-AR.md b/docs/31-siro-operations-runbook-AR.md new file mode 100644 index 00000000..ab1f8a44 --- /dev/null +++ b/docs/31-siro-operations-runbook-AR.md @@ -0,0 +1,321 @@ +# دليل التشغيل والأعطال المعروفة — سيرو + +> **الغرض:** مرجع عملي لكل ما يلزم عند النشر أو النقل إلى سيرفر جديد. +> كل بند هنا عطلٌ وقع فعلاً في الإنتاج، لا احتمالٌ نظري. +> +> **آخر تحديث:** 2026-08-08 — بعد جلسة إصلاح شاملة على إنتاج الأردن. +> مكمّل لـ [30-siro-port-plan.md](30-siro-port-plan.md). + +--- + +## ١. خريطة المنافذ + +المنافذ أكثر ما يُنسى عند النقل، ونسيانها يعطي `timeout` لا رسالة خطأ. + +| المنفذ الخارجي | الخدمة | داخل دوكر | مَن ينهي TLS | UFW | +|---|---|---|---|---| +| 443 | الباك إند + المدفوعات | `nginx` على `HTTP_PORT` | nginx المضيف | مفتوح | +| 2020 | سوكيت السائقين (GPS) | `127.0.0.1:12020` | **nginx المضيف** | يلزم فتحه | +| 3030 | سوكيت الركاب | `127.0.0.1:13030` | **nginx المضيف** | يلزم فتحه | +| 4040 | سوكيت الطعام | `127.0.0.1:14040` | **nginx المضيف** | يلزم فتحه | +| — | HTTP داخلي للطعام | `socket_food:4041` | لا شيء | داخلي | +| — | HTTP داخلي للسائقين | `socket_driver:2021` | لا شيء | داخلي | + +> ⚠️ **`HTTP_PORT` ليس 8080.** في إنتاج الأردن هو **8182**، ويُقرأ من `docker/.env`. +> افتراضه خطأً أضاع في هذه الجلسة ثلاث محاولات تشخيص متتالية: كل `curl` على 8080 +> كان يضرب خدمة أخرى تماماً، فبُنيت على نتائجه نظريات خاطئة عن DNS وعن العناوين +> المهجورة. **اقرأ المنفذ قبل أي قياس:** +> ```bash +> docker compose port nginx 80; grep '^HTTP_PORT' .env +> ``` + +### قاعدة السوكيت الجديد — ثلاث خطوات لا واحدة + +حاويات Workerman تفتح منافذها **نصاً صريحاً**، والتطبيق يطلب `https://`. مصافحة TLS +ضد منفذ لا يتكلم TLS تتجمّد حتى المهلة. لذلك كل سوكيت يحتاج: + +1. نشر داخلي على `127.0.0.1:1XXXX` في `docker-compose.yml` +2. كتلة TLS في `docker/nginx/siro-sockets-tls.conf` ← **تُنسخ يدوياً** إلى `/etc/nginx/sites-enabled/` +3. `ufw allow XXXX/tcp` + +نسيان أيٍّ منها يعطي `timeout`. وقد وقع هذا **مرتين**: أولاً في 2020/3030، ثم تكرّر +حرفياً مع الطعام (4040) رغم أن الملف نفسه يشرح العلّة في تعليقه. + +### تشخيص أي عطل سوكيت — ثلاثة أوامر + +```bash +ss -ltnp | grep ':PORT\b' # هل يستمع المضيف؟ +curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:INTERNAL/ # هل الحاوية حيّة؟ +ufw status numbered | grep PORT # هل المنفذ مفتوح؟ +``` + +ردّ **403** من الأمر الثاني = الحاوية سليمة وترفض لغياب JWT. هذا نجاح لا فشل. + +> **فخ UFW:** نشر منافذ Docker **يتجاوز UFW** عبر قواعد iptables خاصة. فما دام +> `docker-proxy` يملك المنفذ يكون مفتوحاً للعالم رغم أن UFW لا يسمح به — ولحظة +> نقله إلى nginx تنطبق قواعد UFW ويُحجب بصمت. `curl` من السيرفر نفسه **لا يُثبت** +> الوصول الخارجي. + +--- + +## ٢. طبقتا nginx — أخطر التباس في المعمارية + +``` +العميل → nginx المضيف (CloudPanel، 443) → nginx الحاوية (HTTP_PORT) → php-fpm +``` + +**تمييز مصدر الخطأ من جسم الردّ:** + +| الجسم | المصدر | المعنى | +|---|---|---| +| `File not found.` (16 بايت) | **php-fpm** | الملف غير موجود في الحاوية التي وصلها الطلب | +| صفحة HTML بعنوان 404 | nginx | `try_files` لم يجد الملف | +| `502 Bad Gateway` | nginx | لم يصل الـ upstream إطلاقاً | +| `{"error":"..."}` | التطبيق | وصل ونُفِّذ | + +### فخ عنوان الـ upstream المهجور + +nginx يترجم الاسم المكتوب حرفياً في `fastcgi_pass` **مرة واحدة** عند تحميل +الإعدادات ويحتفظ به للأبد. عند إعادة إنشاء الحاويات تتبدّل العناوين، فيبقى nginx +يطرق عنواناً مهجوراً ⇒ **502 والحاوية سليمة وسجلّها صامت** لأن الطلبات لا تصلها. + +**الحل المطبَّق** في `docker/nginx/default.conf`: وجهات كمتغيّرات مع +`resolver 127.0.0.11 valid=10s`، فتُؤجَّل الترجمة إلى وقت الطلب. + +فائدة ثانية: بالاسم الحرفي كان nginx **يرفض الإقلاع** إن كانت إحدى حاويات fpm +متوقفة — أي أن سقوط حاوية الطعام وحدها كان قادراً على إسقاط الموقع بأكمله عند أول +إعادة تشغيل. + +### فخ المزامنة اليدوية + +`siro-sockets-tls.conf` يعيش على المضيف ويُنسخ يدوياً من المستودع. لا شيء يربط +الاثنين، ولا شيء يربط إضافة سوكيت في `docker-compose.yml` بإضافة كتلته هنا. +**تحقّق دائماً بعد الـ pull وقبل الـ cp:** + +```bash +git pull && grep -c "listen 4040" docker/nginx/siro-sockets-tls.conf +``` + +وخذ نسخة احتياطية قبل النسخ — الملف يحمل سوكيت الرحلات العامل: + +```bash +cp /etc/nginx/sites-enabled/siro-sockets-tls.conf /root/siro-sockets-tls.conf.bak +cp docker/nginx/siro-sockets-tls.conf /etc/nginx/sites-enabled/ && nginx -t +``` + +لا تُعِد التحميل إلا بعد `test is successful`. تحذيرات `ssl_stapling` ضجيج قديم +لا علاقة له. + +--- + +## ٣. قواعد البيانات + +### الخريطة + +`backend/core/Database/Database.php` يُفرد لكل قاعدة **host وuser وpass مستقلة** — +الفصل على خوادم مختلفة مفترضٌ في التصميم: + +| الاسم | متغيّر البيئة | القيمة في الأردن | +|---|---|---| +| `main` | `DB_PRIMARY_NAME_V2` | `jorSiroDB` | +| `ride` | `DB_RIDE_NAME` | `intaleq-ridesDB` | +| `tracking` | `DB_TRACKING_NAME` | `locationDB` | +| `transit` | `DB_TRANSIT_NAME` | `siroTransitDb` | +| `food` | `DB_FOOD_NAME` | `siro_food` | +| **(خارج الخريطة)** | `DB_PAYMENT_NAME` | `payment` | + +> `DB_PRIMARY_NAME` (بلا `_V2`) = `intaleqDB1` وهي **قاعدة أخرى**. أي كود يقرأ +> المتغيّر بلا `_V2` يعمل على قاعدة خاطئة صامتاً. + +### ممنوع: الإشارة المؤهَّلة عبر القواعد + +`SELECT ... FROM jorSiroDB.ride` تعمل **فقط** إن كانت القاعدتان على نفس خادم +MySQL. تعمل اليوم بالصدفة وتسقط يوم يُفصل الدفع. + +**الصحيح:** اتصال ثانٍ مستقل — انظر `payment_server/v2/main/db_primary.php`. + +### قاعدة الفصل بين القراءة والكتابة + +- **قراءة** بيانات غير مالية (عدّ رحلات، إحصاء): اتصال مباشر ✅ +- **كتابة** في المحفظة: **S2S عبر خادم الدفع حصراً** ❌ لا اتصال مباشر + +السبب أن نقطة S2S هي البوابة الوحيدة التي تجمع JWT وHMAC ومنع التكرار والأثر. +وهي ناقصة أصلاً: `driverWallet.paymentID` **بلا مفتاح فريد وبلا فحص تكرار** رغم +أن التعليقات تدّعي عكس ذلك. توزيع الكتابة على كل من يملك اتصالاً يجعل إصلاح ذلك +مستحيلاً لاحقاً. + +### الترحيلات — لا جدول تتبّع + +كل ملف في `backend/migrations/` يُطبَّق يدوياً ولا سجل لما نُفِّذ. + +```bash +docker compose exec -T mysql mysql -uroot -p"$MP" jorSiroDB < backend/migrations/FILE.sql +``` + +> ⚠️ `2026_08_07_verify_all.sql` **لا يفحص كل جداول دفعته** — لا يذكر +> `scheduled_rides` ولا جداول SMS. خروجه نظيفاً **ليس** دليلاً على الاكتمال. + +عند أي خطأ قاعدة من endpoint جديد، افحص وجود الجدول مباشرة قبل قراءة الكود: + +```sql +SELECT TABLE_SCHEMA, TABLE_NAME FROM information_schema.TABLES WHERE TABLE_NAME='X'; +``` + +إعادة التشغيل: ما يستعمل `siro_add_column` من `_helpers.sql` آمن؛ وما يستعمل +`ALTER TABLE ... ADD COLUMN` خاماً **يفشل** إن أُعيد (مثل `2026_08_09_obligation_credit_gate`). + +--- + +## ٤. PHP في الحاويات + +**صورة `php:fpm` الرسمية لا تشحن `php.ini` إطلاقاً**، فتسري القيم المدمجة و +`display_errors` **مُفعّل**. أي أن كل تحذير يُطبع **داخل جسم الردّ** قبل الـ JSON. + +الأثر الحقيقي: شاشة الرصيد كانت تسقط بـ +`FormatException: Unexpected character (at character 1)
` — والعطل يبدو في +التطبيق بينما مصدره سطر تحذير في PHP. وأمنياً، نص التحذير يحمل المسار المطلق +وأحياناً جزءاً من الاستعلام ويُسلَّم للعميل. + +**الحل:** `docker/php/php-prod.ini` مركّب على `php` و`php_food` و`php_transit`. +تحقّق بعد أي إعادة إنشاء: + +```bash +docker compose exec -T php php -i | grep -E "^display_errors|^upload_max_filesize" +# المتوقّع: Off / 25M +``` + +> ضبط `upload_max_filesize=25M` ليوافق `client_max_body_size` في nginx — كان على +> 2M الافتراضية، وهو ما يقطع رفع صور المستندات بصمت. + +**النشر:** `backend` مربوط bind-mount و opcache على `validate_timestamps=1` +بتحديث كل 60ث ⇒ **لا حاجة لإعادة بناء**، يكفي `git pull`. أما تغيير +`docker-compose.yml` فيلزمه `docker compose up -d`. + +--- + +## ٥. نظام التوكن (JWT) + +### الأنواع + +| النوع | مَن يُصدره | المسموح له | +|---|---|---| +| `registration` | `loginFirstTimeDriver.php` | مسارات التسجيل فقط (`REGISTRATION_ENDPOINTS`) | +| `access` role=driver | `loginJwtDriver.php` / `auth/otp/verify.php` | كل شيء | + +توكن التسجيل صالح **ساعة كاملة** لكنه بلا صلاحيات. وأي endpoint محمي يردّه بـ +**403 `Token not authorized for this action`**. + +### العطل الذي عطّل كل شيء + +`getJWT` كان يفحص `exp` وحده. فتوكن التسجيل يبقى مخزّناً بعد اكتمال الدخول، ويردّ +كل endpoint محمي 403 — و`restart` لا ينفع لأن `force` يمسح الـ cooldown فقط. +وسطر الترقية بعد الدخول كان معطَّلاً بالتعليق. + +**الحل (مطبَّق):** فحص `token_type` لا `exp` وحده، وترقية فورية بعد `loginDriver`، +و`CRUD` يعالج 403 كما يعالج 401 (تجديد واحد ثم إعادة). + +### بصمة الجهاز + +`driverToken.fingerPrint` كانت تُكتب **بصيغتين** حسب آخر endpoint لمسها: خام من +`verify.php` و`addDriver.php`، ومُهشّمة (`sha256(fp+FP_PEPPER)`) من +`loginJwtWalletDriver.php`. و`loginJwtDriver.php` يقارن بالمُهشّمة وحدها ⇒ رفض دائم +لكل سائق OTP لم يفتح المحفظة. + +**الحل:** `loginJwtDriver.php` يقبل الصيغتين ويوحّد الصفّ على المُهشّمة. والتطبيق +يتخطّى المقارنة عند رؤية 64 خانة hex (لا يملك الـ pepper). + +### مسار الجهاز الجديد + +جهاز جديد ⇒ `loginJwtDriver` يردّ 401 ⇒ العلم `needsDeviceVerification` ⇒ شاشة OTP +⇒ `verify.php` يربط البصمة **ويُصدر توكن driver** ⇒ يُحفظ. كان الفرع `token_change` +لا يفعل أياً من الاثنين، فتنجح شاشة الـ OTP ولا يعمل بعدها شيء. + +--- + +## ٦. سجل الملفات المعدَّلة + +### الباك إند + +| الملف | العطل | الدرس | +|---|---|---| +| `core/Auth/JwtService.php` | — (مرجع) | `REGISTRATION_ENDPOINTS` تُطابق بـ `basename`؛ أي endpoint جديد في التسجيل يجب إضافته | +| `loginJwtDriver.php` | بصمة بصيغتين ⇒ 401 دائم | وحّد صيغة التخزين، واقبل القديمة للتوافق | +| `auth/otp/verify.php` | `token_change` لا يُصدر توكناً ولا يربط البصمة | التحقق وحده لا يكفي — لا بد من ربط وإصدار | +| `ride/gamification/getLeaderboard.php` | 4 أعمدة غير موجودة (`d.name`, `nameArabic`, `firstName`, `personal_photo`) | الأعمدة الفعلية `first_name`/`last_name` و**مشفّرة** — تحتاج فكّ تشفير | +| `ride/scheduled/list.php` | `WHERE driver_id` وجدول `scheduled_rides` **بلا هذا العمود** | الحجز يُنشئه الراكب؛ السائق يُسند لحظة توليد الرحلة — الربط عبر `ride_id` | +| `ride/invitor/referral_code_helper.php` | **جديد** | المولّد كان في نقطة لا يناديها أحد ⇒ الكود `null` للجميع. القراءة نفسها تُنشئ | +| `ride/invitor/get_driver_referrals.php` | تقرأ ولا تولّد | — | +| `ride/invitor/get_passenger_referrals.php` | نفسه | — | +| `ride/invitor/get_unified_code.php` | `while(true)` تُعلّق العامل لو امتلأت الأكواد | سقف محاولات ثم بديل أطول | + +### خادم الدفع + +| الملف | العطل | الدرس | +|---|---|---| +| `db_primary.php` | **جديد** | اتصال ثانٍ للقاعدة الأساسية — لا إشارة مؤهَّلة | +| `ride/payment/getAllPayment.php` | `Table 'payment.ride' doesn't exist` بلا `try/catch` ⇒ 500 | افصل الرصيد عن الإحصاء: المالي لا يسقط لأجل المساعد | +| `ride/driverWallet/driverStatistic.php` | نفسه (`driver_orders` + `ride`) | — | + +### تطبيق السائق + +| الملف | العطل | الدرس | +|---|---|---| +| `controller/auth/captin/login_captin_controller.dart` | فحص `exp` وحده؛ الترقية معلّقة | صلاحية التوكن ليست زمنه فقط بل نوعه | +| `controller/functions/crud.dart` | 403 بلا معالجة؛ حجب النقاط العامة عند غياب التوكن | حجب `/auth/otp/` أغلق مسار الاستعادة نفسه | +| `controller/auth/captin/phone_helper_controller.dart` | يرمي التوكن الذي يصدره السيرفر | — | +| `controller/auth/captin/opt_token_controller.dart` | نفسه | — | +| `controller/auth/captin/invit_controller.dart` | **يرفع دفتر الهاتف كاملاً** تلقائياً | ⚠️ انظر §7 | +| `controller/home/payment/captain_wallet_controller.dart` | `isLoading` بلا `finally` ⇒ لودينج أبدي لكل سائق جديد | كل مسار تحميل يحتاج `finally` | +| `controller/home/captin/destination_controller.dart` | يفهرس نصاً كخريطة | الردّ قد يكون `String` عند الفراغ | +| `controller/gamification/leaderboard_controller.dart` | `jsonDecode` على Map | **`CRUD().post` تُرجع مفكوكاً، و`get` تُرجع نصاً** | +| `controller/scheduled/driver_scheduled_rides_controller.dart` | يعرض «لا حجوزات» عند فشل 500 | افصل الخطأ عن الفراغ — الأول يُضلّل الكابتن | +| `views/.../drawer_captain.dart` | `onBackgroundImageError` بلا صورة ⇒ assertion | — | +| `views/.../home_captin.dart` | فيض في الشريط العلوي | ما زال 9.8px — الشعار 36px مقابل 26.2px | +| `views/home/statistics/widgets/stat_summary_card.dart` | فيض 9.7px | — | + +--- + +## ٧. بنود مفتوحة + +**دفتر الهاتف (أولوية عليا).** أُوقف الرفع التلقائي، لكن ما جُمع سابقاً ما زال في +`ride/egyptPhones` — أسماء وأرقام أطراف ثالثة لم تُستأذن. رفع دفتر العناوين جملةً +يخالف سياسة Google Play ويتطلب إفصاحاً بارزاً وموافقة صريحة؛ إذن النظام وحده لا +يكفي. **قرار الحذف لم يُتخذ بعد.** + +**الفيض في الشريط العلوي.** تقلّص من 16px إلى 9.8px؛ يحتاج تصغير الشعار نفسه. + +**حارس المزامنة.** لا شيء يقارن `docker/nginx/siro-sockets-tls.conf` بنظيره على +المضيف. مقترح: سطر تحقق في `deploy.sh`. + +**سبب انقطاع الـ 404 الشامل** الذي وقع 2026-08-08 لم يُوثَّق — يُرجَّح أن CloudPanel +أعاد توليد ملف الدومين. **إن صحّ فسيتكرّر عند أول تجديد شهادة.** + +**خطط التأمين** غير مزروعة (`insurance_plans` فارغ) — الصفحة تعرض حالة فارغة صحيحة. + +**الرحلات المجدولة للسائق** تعرض ما أُسند له فقط. إن أُريد أن يرى حجوزات منطقته +قبل الإسناد فذاك تصميم مختلف. + +--- + +## ٨. ترتيب النشر الآمن + +```bash +# ١. اسحب وتحقّق أن ما تتوقعه وصل فعلاً +git pull && grep -c "listen 4040" docker/nginx/siro-sockets-tls.conf + +# ٢. ملفات المضيف (إن تغيّرت) — بنسخة احتياطية وفحص +cp /etc/nginx/sites-enabled/siro-sockets-tls.conf /root/backup.conf +cp docker/nginx/siro-sockets-tls.conf /etc/nginx/sites-enabled/ && nginx -t && systemctl reload nginx + +# ٣. الحاويات (فقط إن تغيّر compose أو ملفات ini) +cd docker && docker compose up -d php php_food php_transit + +# ٤. تحقّق بالقياس لا بالافتراض +docker compose exec -T php php -i | grep ^display_errors +curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:$(grep '^HTTP_PORT' .env | cut -d= -f2)/backend/auth/packageInfo.php +# المتوقّع 401 +``` + +> **القاعدة الحاكمة لكل ما سبق:** لا تستنتج من قياس لم تتحقق من صحة أداته. +> ثلاث نظريات خاطئة في هذه الجلسة — عن الرفع، وعن DNS، وعن العناوين المهجورة — +> بُنيت جميعها على `curl` لمنفذ مفترض. قراءة `HTTP_PORT` من `.env` أسقطتها دفعة واحدة.