ست طبقات تعمل في الإنتاج، كل واحدة مبنية ليوم تسقط فيه الطبقة التي فوقها: RLS مُجبَر، وحسابات لا تتجاوزه، وحاويات مقفلة، وشهادات للهواتف، ونسخة احتياطية تُستعاد كل أسبوع.
كيف أُبقي وحدي نظامًا محاسبيًا من 322 ألف سطر صحيحًا: بوابة حفظ واحدة، وقيود مالية لا تُعدَّل، وسجل تدقيق ببصمات متسلسلة، واختبارات على قواعد حقيقية، وتدقيق كشف ما فاتها.
لماذا تُفسد طابعات ESC/POS الحروف العربية، وكيف تطبعها صورةً أحادية البت بعرض الرأس الحقيقي، وخلل الأبعاد الذي حوّل إيصالًا طوله 12 سم إلى رولٍ كامل ملقى على الأرض.
فوترة محطات الكهرباء دون اتصال: معرّف واحد يرافق الدفعة من الهاتف إلى الإيصال، ولماذا لا تُجمع إجماليات الفواتير، وطابور يعرف ما يعيده، وخطأ 500 صنع حلقة لا تنتهي.
تحفظ FinyxWatt، منصتي لمحطات الكهرباء والمياه، عملاء محطات كثيرة وفواتيرهم وسنداتهم في قاعدة PostgreSQL واحدة. وكل استعلام يرسله الـ API لا يفصله عن كشف عملاء محطة لمحطة أخرى إلا شرط واحد منسي: WHERE tenant_id = ?. أنا من يكتب هذه الاستعلامات. وسيأتي يوم أنسى فيه الشرط.
لذلك لا شيء هنا يعتمد على أن أتذكّره.
هذا دليل ميداني لطريقة حماية FinyxWatt والخادم الذي يعمل عليه هذا الموقع: ست طبقات، كل واحدة مبنية على افتراض أن الطبقة التي فوقها قد سقطت بالفعل. كل إعداد هنا مأخوذ من الملفات الحقيقية، مختصرًا، بلا أسرار ولا أسماء خوادم. وحين تنتهي ستعرف كيف تجعل PostgreSQL ترفض قراءةً نسي كودك تصفيتها، وكيف تشغّل حاويات لا تكاد تستطيع شيئًا، وكيف تمنح هاتفًا هوية لا يمكن تزويرها، وكيف تثبت كل أسبوع أن نسختك الاحتياطية تُستعاد فعلًا.
وفي الطريق ثلاثة فخاخ: إعداد في PostgreSQL يعود سلسلة فارغة، وترويسة يستطيع أي أحد تزويرها لو انضمت حاوية واحدة إلى الشبكة الخطأ، وقيمة افتراضية لإعادة المحاولة تجعل نسخة تالفة تحتجز اختبارها ساعات.
6. الخادمجدار ناري لا يفتح إلا SSH وHTTP وHTTPS، ودخول SSH بالمفاتيح فقط، وتحديثات أمنية تلقائية
5. البوابة (Caddy)HTTPS، وترويسات الأمان، ومسارات إدارة مغلقة، وشهادات الهواتف
4. الحاوياتللقراءة فقط، بلا صلاحيات Linux، بشبكات داخلية، والأسرار ملفات
3. الـ APIرموز متحقَّق منها، والمحطة تُؤخذ من الرمز، لا من الطلب أبدًا
2. أدوار قاعدة البياناتلا مستخدم خارق، ولا تجاوز، ودفتر وسجل تدقيق للإضافة فقط
1. أمان الصفوفكل صف مربوط بعضوية نشطة في محطة المستخدم
ست طبقات بين الإنترنت والبيانات، من الخارج إلى الداخل. يبدأ هذا الدليل من الأسفل، بجوار البيانات. والنسخ الاحتياطية بجانب الطبقات كلها.
الحد المعتاد بين المستأجرين شرط WHERE يتكرر في كل استعلام. يصمد حتى يأتي استعلام كُتب على عجل فيُسقطه، والخطأ عندها صامت: الصفحة تعمل، لكنها تعرض أكثر مما ينبغي.
FinyxWatt تنقل الحد إلى داخل قاعدة البيانات. أمان الصفوف مفعّل ومُجبَر على 88 جدولًا من أصل 94 في ترحيلاتها (migrations). الجداول الستة الباقية تحمل قائمة الصلاحيات، وبيانات المخطط، وسجلات استيراد جرى مرة واحدة. وكل سياسة (policy) تتحقق من محطة الصف، ومن أن المستخدم عضو نشط فيها:
sql
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;ALTER TABLE invoices FORCE ROW LEVEL SECURITY;CREATE POLICY tenant_scope_api ON invoices FOR ALL TO platform_api USING ( tenant_id = app_private.current_tenant_id() AND EXISTS ( SELECT 1 FROM tenant_memberships m WHERE m.tenant_id = app_private.current_tenant_id() AND m.identity_id = app_private.current_identity_id() AND m.status = 'active' ) ) WITH CHECK ( /* الشرط نفسه */ );
الترحيل الذي أضاف شرط العضوية يبدأ بسببه: "خطأ برمجي في المستقبل يجب ألا يحوّل هوية متحققًا منها من المستأجر A إلى المستأجر B لمجرد ضبط خاطئ لإعداد المعاملة". معرّف محطة خاطئ لن يجد عضوية نشطة، فيعود الاستعلام فارغًا.
ويضبط الـ API القيمتين في بداية كل معاملة (transaction)، من الرمز الذي تحقق منه:
الـ true الأخيرة تحصر الإعداد في المعاملة. أما SET العادية فتبقى على الاتصال، فيسلّمه الـ pool إلى الطلب التالي وهو ما زال يشير إلى المحطة A.
السلسلة الفارغة هي الفخ الأول. على اتصال جديد، الإعداد الذي لم يُعرَّف قط يُقرأ NULL. أما على اتصال من الـ pool خدم طلبًا من قبل، فيُقرأ ''، و''::uuid يرمي خطأ. والدالة التي تستدعيها كل سياسة تعالج الحالتين:
sql
CREATE FUNCTION app_private.current_tenant_id() RETURNS uuidLANGUAGE sql STABLE PARALLEL SAFE AS $function$ SELECT nullif(current_setting('app.tenant_id', true), '')::uuid;$function$;
وNULL لا يساوي شيئًا، فالطلب الذي يصل بلا سياق يرى صفر صفوف. يفشل مغلقًا (fail closed).
FORCE مهم لأن مالك الجدول يتخطى سياساته افتراضيًا، والمستخدم الخارق وأدوار BYPASSRLS تتخطاها دائمًا. وهذه مهمة الطبقة الثانية.
ويطبّق الـ CI كل الترحيلات على PostgreSQL جديدة، ويشغّل اختبارات SQL هجومية بدور الـ API نفسه. هذا قلب واحد منها، جاهز للنسخ:
sql
SET ROLE platform_api;SET LOCAL app.tenant_id = '<station A>';SET LOCAL app.identity_id = '<an active member of station A>';DO $test$BEGIN IF (SELECT count(*) FROM customers) <> 1 THEN RAISE EXCEPTION 'RLS expected one station-A customer'; END IF; BEGIN INSERT INTO customers (tenant_id, subscriber_number, display_name) VALUES ('<station B>', 'ILLEGAL', 'Cross-tenant write'); RAISE EXCEPTION 'RLS accepted a cross-tenant write'; EXCEPTION WHEN insufficient_privilege THEN NULL; END;END $test$;
كل هذا يفترض أن الـ API يتصل بدور تنطبق عليه السياسات.
اتصل بصفة المالك دون FORCE، أو بمستخدم خارق، أو بدور BYPASSRLS، وتصير كل السياسات السابقة زينة.
قاعدة FinyxWatt تنشئ حساباتها عند أول تشغيل، "قبل أن يُسمح لصورة الـ API بالاتصال أصلًا"، بتعبير السكربت. مختصرًا، والسكربت الحقيقي يأخذ الأسماء من البيئة وكلمات المرور من ملفات سرية:
sql
CREATE ROLE migrator LOGIN NOSUPERUSER NOCREATEDB CREATEROLE NOREPLICATION NOBYPASSRLS;CREATE ROLE api_login LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;ALTER DATABASE app OWNER TO migrator;REVOKE CONNECT ON DATABASE app FROM PUBLIC;GRANT CONNECT ON DATABASE app TO migrator, api_login;GRANT platform_api TO api_login; -- الدور الذي كُتبت له كل السياسات
حساب الترحيل وحده يملك المخطط. لا يحمل أي حساب تشغيل كلمة مرور الترحيل، والـ API يعمل بـ RUN_MIGRATIONS: "false". وخمسة أدوار تشغيل تتقاسم العمل:
الدور
يستطيع
لا يستطيع
platform_api
بيانات المحطة عبر السياسات، والإضافة إلى الدفتر وسجل التدقيق
تعديل قيد في الدفتر أو حدث تدقيق، أو حذفه
platform_worker
تسليم الإشعارات ومهام الرسائل من الطابور
أن يتقاسم مع الـ API حسابه أو كلمة مروره
platform_control
لوحة تحكم المنصة
العمل دون هوية منصة في المعاملة
platform_auditor
قراءة سجل التدقيق وسجل التغييرات
كتابة أي شيء
platform_sms_enrollment
الرموز المؤقتة وطلبات الهواتف المعلّقة
رؤية مهام الرسائل أو الأجهزة أو سجلات التدقيق أو العملاء
والدفتر وسجل التدقيق يرفضان التعديل مرتين:
sql
GRANT SELECT, INSERT ON ledger_entries TO platform_api; -- لا UPDATE ولا DELETECREATE FUNCTION app_private.prevent_ledger_mutation() RETURNS triggerLANGUAGE plpgsql SECURITY INVOKER SET search_path = pg_catalog, public AS $function$BEGIN RAISE EXCEPTION 'ledger entries are append-only; post a reversal entry instead' USING ERRCODE = 'integrity_constraint_violation';END;$function$;CREATE TRIGGER ledger_entries_append_onlyBEFORE UPDATE OR DELETE ON ledger_entriesFOR EACH ROW EXECUTE FUNCTION app_private.prevent_ledger_mutation();
قد يوسّع ترحيل لاحق صلاحية بالخطأ. يبقى الـ trigger رافضًا، ورسالته تخبر المطوّر التالي بما يفعله بدلًا من ذلك: القيود للإضافة فقط، والتصحيح يكون بقيد عكسي.
sql
-- حسابات تتخطى أمان الصفوفSELECT rolname FROM pg_rolesWHERE rolcanlogin AND (rolsuper OR rolbypassrls);-- جداول ليس عليها أمان صفوف مفعّل ومُجبَر معًاSELECT relname FROM pg_classWHERE relkind = 'r' AND relnamespace = 'public'::regnamespace AND NOT (relrowsecurity AND relforcerowsecurity);
الأدوار تحدد ما يحق للحساب فعله. أما المحطة التي يعمل باسمها فتأتي من رمز.
تسجّل FinyxWatt دخول المستخدمين عبر Keycloak. والـ API يتحقق من توقيع كل رمز، ومُصدِره، والجهة الموجَّه إليها، وصلاحيته الزمنية، مقابل المفاتيح التي ينشرها Keycloak:
ثم يقرأ الـ claim المتحقَّق منه azp ليعرف أي تطبيق حصل على الرمز. والتعليق فوق هذا الفحص هو درس الطبقة كله: "لا تقبل أبدًا ترويسة تحدد نوع التطبيق: يستطيع المتصل تزويرها ليلتفّ برمز أندرويد على مسار لا يقبل إلا الـ cookies".
والمحطة تتبع القاعدة نفسها. الـ API لا يقبل إلا عضويات الهوية المتحقق منها، ثم يعيد قراءة العضوية تحت RLS. والجلسات قصيرة، ورموزها لمرة واحدة:
رمز الوصول يعيش خمس دقائق. ورمز التحديث يعمل مرة واحدة، فالنسخة المسروقة منه تُستهلك لحظة يستخدمه أي من الطرفين. وخمس كلمات مرور خاطئة تجعل الحساب ينتظر.
حتى الآن كان المهاجم خطأً في كودي أنا. الطبقات التالية تفترض أنه صار داخل حاوية.
لا شيء يُكتب خارج /tmp ووحدتي تخزين الشهادات، وكل صلاحيات Linux مسحوبة إلا ربط المنافذ المنخفضة، ولا عملية تستطيع رفع صلاحياتها، والذاكرة تقف عند 128 ميغابايت.
وFinyxWatt تشغّل عشرين خدمة، كلها بـ no-new-privileges، وبوابتها Caddy للقراءة فقط وبلا أي صلاحيات. والحماية الأقوى هي خريطة الشبكات:
ودليل الأسرار يختصر القاعدة في جملة: "لا تضع الأسرار في وسائط سطر الأوامر، ولا في ملفات .env، ولا في سجل الطرفية، ولا في صور الحاويات، ولا في نظام إدارة الكود". متغير البيئة يظهر في docker inspect، أما الملف تحت /run/secrets فلا يوجد إلا حيث رُكِّب.
admin off يطفئ واجهة الإدارة الخاصة بـ Caddy نفسه. ولوحات المراقبة لا تجيب إلا من شبكات موثوقة، ثم تطلب كلمة مرور لا يحفظ الخادم منها إلا تجزئة bcrypt. والمسار /metrics يعيد 404 من الإنترنت، ومسار تسجيل الهواتف العام لا يقبل أكثر من 32 كيلوبايت، لأنه لا يستقبل ملفًا أبدًا. وكل ترويسة أمان تأتي من مكان واحد: الـ API يضع X-Frame-Options بنفسه، فتتركها كتلة الـ API في Caddy.
أما أمتع مهام البوابة فهي فحص جواز سفر كل هاتف يرسل الفواتير.
ترسل FinyxWatt فاتورة كل عميل برسالة قصيرة من هواتف أندرويد مخصصة في المحطة. والهاتف الذي يرسل باسم محطة يحتاج أكثر من كلمة مرور، فيحصل على شهادة عميل (client certificate).
على الهاتفيُنشأ مفتاح داخل Android Keystore ولا يغادره أبدًا
إلى خدمة التسجيلرمز لمرة واحدة وإثبات امتلاك المفتاح ينشئان طلبًا معلّقًا، لا أكثر
مدير ثانٍ يراجعمنشئ الرمز لا يحق له الاعتماد، والاعتماد يربط شهادة بالمفتاح المُثبَت
عودة إلى الهاتفتُثبَّت الشهادة
كل اتصالCaddy يشترط الشهادة ويتحقق منها، ثم يكتب ترويسة البصمة بنفسه
كيف يكسب هاتف الرسائل شهادته. المفتاح الخاص لجهة الإصدار لا يلمس الخادم أبدًا.
قاعدة البيانات لا تحفظ إلا HMAC للرمز، مصنوعًا بقيمة سرية (pepper)، والمخطط نفسه يفرض الحدود:
sql
-- HMAC-SHA-256(code-id || secret), never the raw enrollment code.code_digest bytea NOT NULL CHECK (octet_length(code_digest) = 32),expires_at timestamptz NOT NULL,max_attempts smallint NOT NULL DEFAULT 5 CHECK (max_attempts BETWEEN 1 AND 10),attempts smallint NOT NULL DEFAULT 0 CHECK (attempts BETWEEN 0 AND 10),CONSTRAINT sms_device_enrollment_codes_attempts_bound CHECK (attempts <= max_attempts),CONSTRAINT sms_device_enrollment_codes_expiry CHECK (expires_at > created_at)
والاعتماد يمر عبر الـ API الرئيسي، الذي يرفض منشئ الرمز بالخطأ SMS_ENROLLMENT_SELF_APPROVAL_FORBIDDEN، ويربط شهادة من جهة إصدار خاصة يطابق مفتاحها العام المفتاحَ الذي أثبت الهاتف امتلاكه. ومن بعدها:
وهنا الفخ الثاني. بوابة الرسائل تصدّق ترويسة البصمة دون سؤال، وهذا آمن لسبب واحد: البوابة تقع على شبكة داخلية لا ينضم إليها إلا Caddy، وCaddy يكتب الترويسة فوق أي قيمة سابقة بعد أن يتحقق من الشهادة. وكود البوابة نفسه يشرح كيف يُكسر هذا: "لا تنشر منفذ حاوية البوابة، ولا تضف حاويات عشوائية إلى تلك الشبكة؛ أيٌّ منهما يجعل هذه الترويسة قابلة للتزوير". سطر ports: واحد يُضاف لتجربة سريعة، ويصير بوسع أي أحد أن يرسل الترويسة بنفسه.
والـ proxy يخفي فخًا آخر. كل طلب يصل إلى البوابة بعنوان Caddy، فحدّ الطلبات لكل IP سيعطي الهواتف كلها ميزانية واحدة مشتركة. لذلك تبني البوابة حدّها على الشهادة المتحقق منها:
جُهّز خادم هذا الموقع بسكربت واحد، والجزء الخاص بـ SSH فيه حريص على الترتيب:
bash
if [ -s "/home/$USER_NAME/.ssh/authorized_keys" ]; then cat >/etc/ssh/sshd_config.d/10-moghalis.conf <<'EOF'PermitRootLogin noPasswordAuthentication noKbdInteractiveAuthentication noEOF sshd -t && systemctl reload sshelse echo "!! No SSH key for $USER_NAME yet: password login left ON."fiufw default deny incomingufw allow OpenSSHufw allow 80/tcpufw allow 443/tcpufw allow 443/udpufw --force enable
الدخول بكلمة المرور لا يُطفأ إلا إن وُجد مفتاح، وSSH لا يُعاد تحميله إلا إن قبل sshd -t الإعداد، والجدار الناري يسمح بـ SSH قبل أن يُرفع. اعكس أيًّا من الثلاثة على VPS جديد، وستكون محطتك التالية شاشة الإنقاذ عند مزوّد الخادم.
ست طبقات، كل واحدة تفترض أن التي فوقها تسقط. والنسخة الاحتياطية تفترض أنها سقطت كلها.
تؤرشف PostgreSQL في FinyxWatt سجل الكتابة المسبق (WAL) كل خمس دقائق على الأقل، وتأخذ نسخة أساسية كاملة كل ليلة عبر WAL-G، وإصدار WAL-G نفسه مثبّت ومتحقق من بصمته. كل ملف يُضغط ويُشفَّر قبل أن يُكتب. وجهاز مكتب المالك يسحب الملفات الجديدة كل أربع ساعات ويحتفظ بخمسة وأربعين يومًا منها.
مفتاح التشفير هو النسخة الاحتياطية نفسها: إن ضاع صار كل ملف ضجيجًا. لذلك هو مختوم مع بقية أسرار الخادم في ملف محمي بكلمة سر، على ذاكرة USB وفي مكان ثانٍ، وكلمة السر محفوظة بعيدًا عنهما.
ولا شيء من ذلك يثبت شيئًا حتى تنجح استعادة. كل يوم جمعة يستعيد سكربت أحدث نسخة أساسية وكل سجل مؤرشف بعدها في وحدة تخزين مؤقتة. مختصرًا:
كل سطر حارس. بلا شبكة، فلا تلمس النسخة قاعدة البيانات الحية. والمستودع والمفتاح للقراءة فقط. لا أرشفة، ولا منفذ يستمع. و$image يُقرأ من حاوية قاعدة البيانات الحية، فيستعيد الفحص بالصورة نفسها التي يعمل بها الإنتاج. وحين تنتهي الاستعادة، يعدّ ما عاد:
sql
SELECT json_build_object( 'migrations', (SELECT count(*) FROM schema_migrations), 'stations', (SELECT count(*) FROM tenants), 'customers', (SELECT count(*) FROM customers), 'bills', (SELECT count(*) FROM invoices), 'receipts', (SELECT count(*) FROM payments), 'newestChange', (SELECT max(changed_at) FROM change_log));
الأعداد تقول إن النسخة تحمل ما ينبغي، وأحدث تغيير يقول كم هي حديثة. ثم يُحذف كل شيء، أيًّا كانت النتيجة. وإن فشل الفحص، أو مرت تسعة أيام بلا فحص، يصلني تنبيه في تطبيق المطوّر.
والآن الفخ الثالث، وسبب WALG_DOWNLOAD_FILE_RETRIES=2. يعيد WAL-G محاولة الملف الذي يتعذر عليه قراءته 15 مرة، تفصل بينها دقائق. وجّهه إلى نسخة تالفة أو لا يمكن فك تشفيرها، ولن يفشل الفحص. سينتظر، لساعات. محاولتان وسقف 45 دقيقة يحوّلان هذا الانتظار إلى فشل واضح مع سببه.
النسخة المشفرةأحدث نسخة أساسية وكل ملفات WAL بعدها، للقراءة فقط
حاوية مختومةبلا شبكة، ولا أرشفة، ولا منفذ يستمع، و45 دقيقة على الأكثر
استعادة PostgreSQLتُبنى القاعدة بالصورة التي يعمل بها الإنتاج
عدّ ما عادالترحيلات والمحطات والعملاء والفواتير والسندات وأحدث تغيير
التنظيفتُحذف الحاوية ووحدة التخزين، والنتيجة تذهب إلى المراقب
فحص الاستعادة كل جمعة. الفشل، أو مرور تسعة أيام بلا فحص، يطلق تنبيهًا.
ومراقب يعمل كل عشر دقائق ويبلّغ عما يكسر النسخ الاحتياطية بصمت: قرص امتلأ 85%، وخدمة توقفت، ونسخة كاملة أقدم من 26 ساعة، وأرشفة WAL متعثرة، وجهاز مكتب لم يسحب النسخ منذ ثلاثة أيام، وشهادة أمان تقترب من انتهائها.
الطبقات تحمي النظام وهو يعمل. والبوابة تحميه من الإصدار القادم.
فحصٌ للمستودع يُفشل البناء عند وجود مفاتيح خاصة، أو رموز وصول، أو رموز JWT، أو رابط قاعدة بيانات فيه كلمة مرور، أو بريد إلكتروني أو رقم هاتف حقيقي، أو أي ملف .env أو secrets/ متتبَّع:
js
['private key material', /-----BEGIN(?: [A-Z]+)? PRIVATE KEY-----/],['GitHub access token', /\bgh[pousr]_[A-Za-z0-9_]{30,}\b/],['database URI with embedded password', /\bpostgres(?:ql)?:\/\/[^\s:@/${]+:[^\s@/${]{8,}@/i],
وهو يجمع الملفات بـ git ls-files --cached --others --exclude-standard، فإن شغّلته محليًا التقط الملف قبل أن تضيفه إلى Git.
ثم يشغّل الـ CI كل الترحيلات على PostgreSQL جديدة مع اختبارات عزل المحطات، واختبارات الخلفية، واختبارات التكامل بدور قاعدة البيانات المقيّد، وnpm audit --omit=dev --audit-level=high. والنشر التلقائي ينتظر ذلك كله:
في هذه الأسطر ثلاثة فخاخ. workflow_run ينطلق حين يكتمل الـ CI، نجح أو فشل، فبدون شرط if ينشر البناء الأحمر. وسحب main بدل head_sha قد ينشر commit لم يختبره الـ CI قط. والنشر الذي يُلغى في منتصفه يترك خادمًا نصف مُرحَّل. والـ job يثبّت بصمة مفتاح الخادم قبل أول أمر SSH.
أمان الصفوف مُجبَر على كل جدول للمستأجرين، وسياساته تتحقق من عضوية نشطة.
سياق المستأجر لكل معاملة بـ set_config(..., true)، من الرمز، ويُقرأ عبر nullif.
لا حساب تطبيق هو المالك أو مستخدم خارق أو BYPASSRLS، وحساب لكل مهمة.
جداول الدفتر والتدقيق بلا صلاحيات UPDATE أو DELETE، ومعها trigger يرفضهما.
الأسرار ملفات تُركَّب حيث يلزم فقط، والـ CI يفشل عند المفاتيح والرموز وملفات .env المتتبَّعة.
حاويات للقراءة فقط، بلا صلاحيات، بحد للذاكرة، على شبكات داخلية.
ترويسات الهوية لا تُصدَّق إلا على شبكة مغلقة، وحدود الطلبات مبنية على تلك الهوية.
نسخ احتياطية مشفرة، ومفتاحها بعيد عنها، ونسخة خارج الخادم، وفحص استعادة بحد زمني وتنبيه.
لا نشر إلا بعد CI أخضر، ومن الـ commit المختبَر، ولا إلغاء في المنتصف.
لا طبقة من هذه ذكية وحدها. القوة في الافتراض الذي تحتها كلها: الطبقة التي فوقك سقطت بالفعل، وعليك أن تصمد مع ذلك. اكتب هذا الافتراض في إعداداتك، ويصير اليوم الذي تسقط فيه إحداها يومًا عاديًا.
وإن كنت تفضّل أن يتولى أحد هذا كله لنظامك، فهذا ما تغطيه الاستضافة والدعم.
التعليقات
شارك في النقاش
سجّل الدخول بحساب Google لتعجب بالمقالات وتعلّق عليها. ضغطة واحدة تكفي.