Files
Siro/docs/31-siro-operations-runbook-AR.md
T

18 KiB
Raw Blame History

دليل التشغيل والأعطال المعروفة — سيرو

الغرض: مرجع عملي لكل ما يلزم عند النشر أو النقل إلى سيرفر جديد. كل بند هنا عطلٌ وقع فعلاً في الإنتاج، لا احتمالٌ نظري.

آخر تحديث: 2026-08-08 — بعد جلسة إصلاح شاملة على إنتاج الأردن. مكمّل لـ 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 وعن العناوين المهجورة. اقرأ المنفذ قبل أي قياس:

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) رغم أن الملف نفسه يشرح العلّة في تعليقه.

تشخيص أي عطل سوكيت — ثلاثة أوامر

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:

git pull && grep -c "listen 4040" docker/nginx/siro-sockets-tls.conf

وخذ نسخة احتياطية قبل النسخ — الملف يحمل سوكيت الرحلات العامل:

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/ يُطبَّق يدوياً ولا سجل لما نُفِّذ.

docker compose exec -T mysql mysql -uroot -p"$MP" jorSiroDB < backend/migrations/FILE.sql

⚠️ 2026_08_07_verify_all.sql لا يفحص كل جداول دفعته — لا يذكر scheduled_rides ولا جداول SMS. خروجه نظيفاً ليس دليلاً على الاكتمال.

عند أي خطأ قاعدة من endpoint جديد، افحص وجود الجدول مباشرة قبل قراءة الكود:

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) <br /> — والعطل يبدو في التطبيق بينما مصدره سطر تحذير في PHP. وأمنياً، نص التحذير يحمل المسار المطلق وأحياناً جزءاً من الاستعلام ويُسلَّم للعميل.

الحل: docker/php/php-prod.ini مركّب على php وphp_food وphp_transit. تحقّق بعد أي إعادة إنشاء:

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 فارغ) — الصفحة تعرض حالة فارغة صحيحة.

الرحلات المجدولة للسائق تعرض ما أُسند له فقط. إن أُريد أن يرى حجوزات منطقته قبل الإسناد فذاك تصميم مختلف.


٨. ترتيب النشر الآمن

# ١. اسحب وتحقّق أن ما تتوقعه وصل فعلاً
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 أسقطتها دفعة واحدة.