Update: 2026-08-07 19:10:47

This commit is contained in:
Hamza-Ayed
2026-08-07 19:10:47 +03:00
parent 0db59647eb
commit 611b5e616d
4 changed files with 681 additions and 81 deletions
+4
View File
@@ -101,3 +101,7 @@ GoogleService-Info.plist
# --- Audit/Scan Output --- # --- Audit/Scan Output ---
semgrep_*.json semgrep_*.json
nuclei_results.txt nuclei_results.txt
# سجل لوحة المفاتيح — محلي للسيرفر، لا يُدفع
backend/tools/panel/panel_audit.log
*.env.bak.*
+9 -81
View File
@@ -23,82 +23,10 @@ $envFile = getenv('ENV_FILE_PATH') ?: "$root/docker/.env";
$command = $argv[1] ?? 'audit'; $command = $argv[1] ?? 'audit';
$dryRun = in_array('--dry-run', $argv, true); $dryRun = in_array('--dry-run', $argv, true);
// ═══════════════════════════════════════════════════════════════ // ‏التصنيف كله في env_registry.php — مصدر واحد تشترك فيه هذه الأداة
// التصنيف // ‏واللوحة. نسختان منه كانتا ستتباعدان، وأخطر تباعد هو قائمة المجمّدة:
// ═══════════════════════════════════════════════════════════════ // ‏مفتاح يسقط منها في نسخة يعني لوحةً تعرضه قابلاً للتعديل.
require_once __DIR__ . '/env_registry.php';
/** ‏أسرار داخلية: طرفاها كلاهما لنا. توليدها وتدويرها آمنان. */
const GENERATED = [
'S2S_SHARED_KEY' => 32, // خادم ↔ خادم داخلي
'WEBHOOK_SECRET_KEY' => 32, // هاتف الرسائل ↔ الويب‌هوك
'INTERNAL_SOCKET_KEY' => 32, // الباك إند ↔ خوادم السوكيت
'BOT_SECRET_KEY' => 24, // بوت الأسعار
'FCM_INTERNAL_API_KEY' => 32,
'MIGRATION_ADMIN_KEY' => 24,
'PAYMENT_KEY' => 32,
];
/**
* ‏تدويرها يبطل كل الجلسات القائمة: كل سائق وكل راكب سيُخرَج ويُطالَب
* ‏بتسجيل دخول جديد. ليس عطلاً لكنه حدث تشغيلي — لا يُفعل وقت الذروة.
*/
const SESSION_SCOPED = [
'JWT_SECRET_KEY' => 64,
'SECRET_KEY_HMAC' => 32,
];
/**
* ‏‏⛔ مجمّدة. هذه ليست «مفاتيح يمكن تغييرها من عندنا» رغم أنها ملكنا:
*
* ENC_KEY / ENCRYPTION_KEY_PATH
* ‏تُشفَّر به أسماء الركّاب وهواتفهم وبرديهم ولوحات السيارات
* ‏**المخزَّنة في القاعدة**. تغييره لا يعيد تشفير شيء — يجعل كل
* ‏صف قديم رطانةً لا تُفكّ. لا استرجاع، ولا نسخة احتياطية تنفع
* ‏لأن النسخة نفسها مشفّرة بالمفتاح القديم.
*
* BLIND_INDEX_PEPPER
* ‏أعمدة `*_bidx` محسوبة به ومخزَّنة. تغييره يعني أن كل بحث
* ‏بالهاتف أو البريد يفشل — لا يجد المستخدم حسابه، ويُنشأ له
* ‏حساب مكرر عند أول تسجيل.
*
* TRACKING_SECRET_SALT / FP_PEPPER
* ‏قيم مشتقّة منها مخزَّنة أيضاً.
*
* ‏تدويرها ممكن نظرياً لكنه **ترحيل بيانات** لا تغيير إعداد: فكّ بالقديم
* ‏وإعادة تشفير بالجديد على كل صف، بتوقّف مخطَّط ونسخة احتياطية أولاً.
*/
const FROZEN = [
'ENC_KEY', 'ENCRYPTION_KEY_PATH', 'ENCRYPTION_MODE',
'BLIND_INDEX_PEPPER', 'TRACKING_SECRET_SALT', 'FP_PEPPER',
];
/** ‏لا تُولَّد — تُنسخ من لوحة المزوّد. */
const EXTERNAL = [
'GEMINI_API_KEY', 'NABEH_API_KEY', 'NABEH_PASSWORD', 'NABEH_OTP_APP_KEY',
'ELEVENLABS_API_KEY', 'CREATOMATE_API_KEY', 'SOCIAL_BOT_TOKEN',
'MAIL_PASSWORD', 'SIRO_SMTP_PASSWORD', 'SMS_PASSWORD_EGYPT',
'VOICE_CALL_API_KEY', 'RASEEL_DRIVER_ACCESS_TOKEN',
'FIREBASE_SERVICE_ACCOUNT_PATH', 'SERVICE_ACCOUNT_FILE_PATH',
];
/**
* ‏كلمات مرور قواعد البيانات — صنف قائم بذاته.
*
* ‏تغييرها في .env **لا يغيّر شيئاً في MySQL**. متغيّرات صورة mysql
* ‏(ومنها MYSQL_ROOT_PASSWORD) تُقرأ مرة واحدة فقط عند تهيئة مجلّد
* ‏البيانات الفارغ أول مرة. بعد ذلك كلمة المرور تعيش داخل جدول
* ‏mysql.user لا في .env — فتعديل .env وحده يعني تطبيقاً يحمل كلمة
* ‏لا يقبلها الخادم: انقطاع كامل بلا أي تغيير في القاعدة نفسها.
*
* ‏الترتيب الصحيح: ALTER USER في MySQL أولاً، ثم .env، ثم إعادة تشغيل
* ‏حاويات php (لا mysql، ولا إعادة بناء، ولا مساس بالبيانات إطلاقاً).
*/
const DB_CREDENTIALS = [
'DB_PRIMARY_PASS_V2', 'DB_PRIMARY_PASS', 'DB_TRACKING_PASS',
'DB_RIDE_PASS', 'DB_TRANSIT_PASS', 'DB_FOOD_PASS', 'DB_PAYMENT_PASS',
'MYSQL_ROOT_PASSWORD', 'REDIS_PASSWORD', 'REDIS_MAIN_PASSWORD',
'REDIS_LOCATION_PASSWORD', 'REDIS_AUTH', 'REDIS_MAIN_AUTH',
];
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
// القراءة // القراءة
@@ -148,11 +76,11 @@ $report = static function (string $title, array $keys, array $present,
} }
}; };
$report('أسرار داخلية (تُولَّد وتُدوَّر بحرية)', GENERATED, $present, $isPlaceholder, $toSeed, true); $report('أسرار داخلية (تُولَّد وتُدوَّر بحرية)', ENV_GENERATED, $present, $isPlaceholder, $toSeed, true);
$report('مرتبطة بالجلسات (تدويرها يُخرج الجميع)', SESSION_SCOPED, $present, $isPlaceholder, $toSeed, true); $report('مرتبطة بالجلسات (تدويرها يُخرج الجميع)', ENV_SESSION_SCOPED, $present, $isPlaceholder, $toSeed, true);
$report('⛔ مجمّدة — لا تُدوَّر (بيانات مخزَّنة مشتقّة منها)', FROZEN, $present, $isPlaceholder); $report('⛔ مجمّدة — لا تُدوَّر (بيانات مخزَّنة مشتقّة منها)', ENV_FROZEN, $present, $isPlaceholder);
$report('اعتماد قواعد البيانات (يُغيَّر في MySQL أولاً لا هنا)', DB_CREDENTIALS, $present, $isPlaceholder); $report('اعتماد قواعد البيانات (يُغيَّر في MySQL أولاً لا هنا)', ENV_DB_CREDENTIALS, $present, $isPlaceholder);
$report('طرف ثالث (تُنسخ من لوحة المزوّد)', EXTERNAL, $present, $isPlaceholder); $report('طرف ثالث (تُنسخ من لوحة المزوّد)', ENV_EXTERNAL, $present, $isPlaceholder);
echo "\n" . str_repeat('─', 62) . "\n"; echo "\n" . str_repeat('─', 62) . "\n";
+142
View File
@@ -0,0 +1,142 @@
<?php
/**
* tools/env_registry.php — تصنيف مفاتيح البيئة وقراءة/كتابة .env
* ─────────────────────────────────────────────────────────────
* ‏مصدر واحد للحقيقة تستعمله الأداة السطرية (env_keys.php) واللوحة
* ‏(panel/index.php) معاً. نسختان من التصنيف كانتا ستتباعدان، وأخطر ما
* ‏فيهما التباعد هو قائمة «المجمّدة» — مفتاح يسقط منها في نسخة يعني
* ‏لوحةً تعرضه قابلاً للتعديل.
*/
// ═══════════════════════════════════════════════════════════════
// الأصناف
// ═══════════════════════════════════════════════════════════════
/** ‏أسرار داخلية: طرفاها كلاهما لنا. التوليد والتدوير آمنان. */
const ENV_GENERATED = [
'S2S_SHARED_KEY' => 32,
'WEBHOOK_SECRET_KEY' => 32,
'INTERNAL_SOCKET_KEY' => 32,
'BOT_SECRET_KEY' => 24,
'FCM_INTERNAL_API_KEY' => 32,
'MIGRATION_ADMIN_KEY' => 24,
'PAYMENT_KEY' => 32,
];
/** ‏تدويرها يبطل كل الجلسات القائمة — حدث تشغيلي واعٍ لا روتين. */
const ENV_SESSION_SCOPED = [
'JWT_SECRET_KEY' => 64,
'SECRET_KEY_HMAC' => 32,
];
/**
* ‏⛔ مجمّدة. بيانات مخزَّنة في القاعدة مشتقّة منها:
* ENC_KEY — أسماء الركّاب وهواتفهم ولوحات السيارات مشفّرة به
* BLIND_INDEX_PEPPER — أعمدة *_bidx محسوبة به ومخزَّنة
* TRACKING_SECRET_SALT / FP_PEPPER — قيم مشتقّة مخزَّنة
*
* ‏تغييرها لا يعيد تشفير شيء: يجعل الصفوف القديمة غير قابلة للقراءة
* ‏إلى الأبد، ولا نسخة احتياطية تنفع لأنها مشفّرة بالمفتاح القديم.
* ‏تدويرها ترحيلُ بيانات لا تغييرُ إعداد.
*
* ‏اللوحة تعرض حالتها فقط — لا قيمتها ولا حقل تعديل.
*/
const ENV_FROZEN = [
'ENC_KEY', 'ENCRYPTION_KEY_PATH', 'ENCRYPTION_MODE',
'BLIND_INDEX_PEPPER', 'TRACKING_SECRET_SALT', 'FP_PEPPER',
];
/** ‏لا تُولَّد — تُنسخ من لوحة المزوّد. قابلة للتحرير يدوياً. */
const ENV_EXTERNAL = [
'GEMINI_API_KEY', 'NABEH_API_KEY', 'NABEH_PASSWORD', 'NABEH_OTP_APP_KEY',
'ELEVENLABS_API_KEY', 'CREATOMATE_API_KEY', 'SOCIAL_BOT_TOKEN',
'MAIL_PASSWORD', 'SIRO_SMTP_PASSWORD', 'SMS_PASSWORD_EGYPT',
'VOICE_CALL_API_KEY', 'RASEEL_DRIVER_ACCESS_TOKEN',
];
/**
* ‏كلمات مرور القواعد ← اسم المستخدم ومتغيّر اسم القاعدة.
*
* ‏تغييرها في .env وحده لا يفعل شيئاً: متغيّرات صورة mysql تُقرأ مرة
* ‏واحدة عند تهيئة مجلّد بيانات فارغ، وبعدها كلمة المرور تعيش في
* ‏mysql.user. الترتيب الصحيح ALTER USER ثم .env — واللوحة تفعله
* ‏بهذا الترتيب وتتحقّق قبل أن تكتب.
*/
const ENV_DB_CREDENTIALS = [
'DB_PRIMARY_PASS_V2' => ['user' => 'DB_PRIMARY_USER_V2', 'db' => 'DB_PRIMARY_NAME_V2'],
'DB_PRIMARY_PASS' => ['user' => 'DB_PRIMARY_USER', 'db' => 'DB_PRIMARY_NAME'],
'DB_TRACKING_PASS' => ['user' => 'DB_TRACKING_USER', 'db' => 'DB_TRACKING_NAME'],
'DB_RIDE_PASS' => ['user' => 'DB_RIDE_USER', 'db' => 'DB_RIDE_NAME'],
'DB_TRANSIT_PASS' => ['user' => 'DB_TRANSIT_USER', 'db' => 'DB_TRANSIT_NAME'],
'DB_FOOD_PASS' => ['user' => 'DB_FOOD_USER', 'db' => 'DB_FOOD_NAME'],
];
/** ‏يقرأ .env إلى مصفوفة. */
function envRead(string $file): array
{
if (!is_readable($file)) return [];
$out = [];
foreach (file($file, FILE_IGNORE_NEW_LINES) as $line) {
if (preg_match('/^\s*([A-Z0-9_]+)\s*=\s*(.*)$/', $line, $m)) {
$out[$m[1]] = trim($m[2], "\"' ");
}
}
return $out;
}
/** ‏قيمة نائبة: موجودة شكلاً وغائبة فعلاً. */
function envIsPlaceholder(?string $v): bool
{
if ($v === null || $v === '') return true;
return (bool) preg_match('/^(<?CHANGE_ME|changeme|your[_-]|xxx+|TODO|REPLACE)/i', $v);
}
/**
* ‏يكتب مفاتيح في .env مع نسخة احتياطية.
*
* ‏الاستبدال في المكان يبقي التعليقات المجاورة مفيدة، والمفقود يُلحق
* ‏في النهاية.
*
* @return string|null ‏مسار النسخة الاحتياطية، أو null عند الفشل.
*/
function envWrite(string $file, array $changes): ?string
{
if (empty($changes)) return null;
// ‏.env التالف يوقف كل شيء، وهو خارج git بطبيعته فلا مصدر لاستعادته.
$backup = $file . '.bak.' . date('Ymd_His');
if (!copy($file, $backup)) return null;
@chmod($backup, 0600);
$out = rtrim((string) file_get_contents($file), "\n") . "\n";
$append = [];
foreach ($changes as $name => $value) {
$line = "$name=$value";
if (preg_match('/^\s*' . preg_quote($name, '/') . '\s*=/m', $out)) {
$out = preg_replace(
'/^\s*' . preg_quote($name, '/') . '\s*=.*$/m',
str_replace('$', '\\$', $line),
$out,
1
);
} else {
$append[] = $line;
}
}
if ($append) {
$out .= "\n# ── " . date('Y-m-d H:i') . " ──\n" . implode("\n", $append) . "\n";
}
if (file_put_contents($file, $out) === false) return null;
@chmod($file, 0600);
return $backup;
}
/** ‏سرّ عشوائي بصيغة hex. */
function envGenerate(int $bytes = 32): string
{
return bin2hex(random_bytes($bytes));
}
+526
View File
@@ -0,0 +1,526 @@
<?php
/**
* tools/panel/index.php — لوحة إدارة مفاتيح البيئة
* ─────────────────────────────────────────────────────────────
* ‏⚠️ هذه أعلى نقطة قيمةً في المنظومة: من يصل إليها يملك كل شيء دفعة
* ‏واحدة. لذلك القيود التالية ليست تزييناً، وإزالة أيٍّ منها تُبطل
* ‏الباقي:
*
* ١. ‏الاستماع على 127.0.0.1 فقط — لا تُفتح إلا عبر نفق SSH:
* ssh -L 8099:127.0.0.1:8099 root@server
* ‏ثم http://127.0.0.1:8099 في متصفّحك.
*
* ٢. ‏عبارة مرور منفصلة تماماً عن أي حساب آخر (ENV_PANEL_PASSPHRASE
* ‏مخزَّنة كتجزئة password_hash، لا نصّاً).
*
* ٣. ‏المفاتيح المجمّدة تُعرض حالتها فقط — لا قيمتها ولا حقل تعديل.
* ‏لا يوجد مسار في هذا الملف يكتبها، لا بواجهة ولا بطلب مصنوع.
*
* ٤. ‏كل تغيير يُسجَّل: من أين، ومتى، وأي مفتاح (لا قيمته).
*
* ‏التشغيل (على المضيف، لا داخل الحاوية — يحتاج الوصول إلى docker):
* cd backend/tools/panel && php -S 127.0.0.1:8099
*/
declare(strict_types=1);
require_once __DIR__ . '/../env_registry.php';
// ═══════════════════════════════════════════════════════════════
// القيد ١ — لا شيء غير المضيف المحلي
// ═══════════════════════════════════════════════════════════════
$remote = $_SERVER['REMOTE_ADDR'] ?? '';
if (!in_array($remote, ['127.0.0.1', '::1'], true)) {
http_response_code(403);
exit('Forbidden — local access only.');
}
// ‏ترويسات الوكيل لا تُصدَّق: لو وُضعت اللوحة خلف nginx يوماً، فإن
// ‏X-Forwarded-For تجعل REMOTE_ADDR محلياً دائماً وتُبطل القيد أعلاه.
// ‏وجودها يعني أننا خلف وكيل — وهذا ما لا نريده أصلاً.
foreach (['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_FORWARDED'] as $h) {
if (!empty($_SERVER[$h])) {
http_response_code(403);
exit('Forbidden — panel must not be proxied.');
}
}
$root = dirname(__DIR__, 3);
$envFile = getenv('ENV_FILE_PATH') ?: "$root/docker/.env";
$logFile = __DIR__ . '/panel_audit.log';
session_start([
'cookie_httponly' => true,
'cookie_samesite' => 'Strict',
'use_strict_mode' => true,
]);
/** ‏يسجّل حدثاً. القيم لا تُسجَّل أبداً — أسماء المفاتيح فقط. */
function auditLog(string $file, string $event, string $detail = ''): void
{
$line = sprintf("[%s] %s | %s | %s\n",
date('Y-m-d H:i:s'), $_SERVER['REMOTE_ADDR'] ?? '?', $event, $detail);
@file_put_contents($file, $line, FILE_APPEND);
@chmod($file, 0600);
}
// ═══════════════════════════════════════════════════════════════
// القيد ٢ — عبارة المرور
// ═══════════════════════════════════════════════════════════════
$env = envRead($envFile);
$hash = $env['ENV_PANEL_PASSPHRASE'] ?? '';
if ($hash === '' || !str_starts_with($hash, '$2y$')) {
http_response_code(503);
echo '<meta charset="utf-8"><pre style="font-family:monospace;padding:2rem">';
echo "اللوحة معطّلة: لم تُضبط ENV_PANEL_PASSPHRASE.\n\n";
echo "ولّد تجزئة وضعها في docker/.env:\n\n";
echo " php -r 'echo password_hash(\"عبارتك\", PASSWORD_BCRYPT), PHP_EOL;'\n\n";
echo "ثم: ENV_PANEL_PASSPHRASE='\$2y\$...'\n\n";
echo "اجعلها عبارة لا تشبه أي كلمة مرور أخرى عندك.\n";
echo '</pre>';
exit;
}
$error = '';
if (($_POST['action'] ?? '') === 'login') {
// ‏تأخير ثابت: يجعل تخمين العبارة بطيئاً بما يكفي، وbcrypt نفسه
// ‏يتكفّل بالباقي.
usleep(400_000);
if (password_verify((string) ($_POST['passphrase'] ?? ''), $hash)) {
session_regenerate_id(true);
$_SESSION['ok'] = true;
$_SESSION['csrf'] = bin2hex(random_bytes(16));
auditLog($logFile, 'LOGIN_OK');
} else {
$error = 'عبارة غير صحيحة.';
auditLog($logFile, 'LOGIN_FAIL');
}
}
if (($_GET['logout'] ?? '') === '1') {
session_destroy();
header('Location: ?');
exit;
}
$authed = !empty($_SESSION['ok']);
// ═══════════════════════════════════════════════════════════════
// العمليات
// ═══════════════════════════════════════════════════════════════
$notice = '';
if ($authed && ($_POST['action'] ?? '') !== '' && ($_POST['action'] ?? '') !== 'login') {
if (!hash_equals((string) ($_SESSION['csrf'] ?? ''), (string) ($_POST['csrf'] ?? ''))) {
http_response_code(400);
exit('CSRF mismatch');
}
$key = (string) ($_POST['key'] ?? '');
$action = (string) $_POST['action'];
// ── الحارس الجامع ──
// ‏قبل أي فرع: المجمّدة ممنوعة. الفحص هنا لا في كل فرع، فلا يمكن
// ‏لفرع جديد يُضاف لاحقاً أن ينساه.
if (in_array($key, ENV_FROZEN, true)) {
http_response_code(403);
auditLog($logFile, 'BLOCKED_FROZEN', $key);
exit('هذا المفتاح مجمّد ولا يمكن تعديله من اللوحة.');
}
$rotatable = ENV_GENERATED + ENV_SESSION_SCOPED;
if ($action === 'rotate' && isset($rotatable[$key])) {
$backup = envWrite($envFile, [$key => envGenerate($rotatable[$key])]);
if ($backup) {
auditLog($logFile, 'ROTATE', $key);
$notice = "دُوِّر $key. نسخة احتياطية: " . basename($backup)
. ' — أعد تشغيل حاويات php.';
} else {
$error = 'تعذّرت الكتابة.';
}
} elseif ($action === 'set' && in_array($key, ENV_EXTERNAL, true)) {
$value = trim((string) ($_POST['value'] ?? ''));
if ($value === '') {
$error = 'القيمة فارغة.';
} else {
$backup = envWrite($envFile, [$key => $value]);
if ($backup) {
auditLog($logFile, 'SET_EXTERNAL', $key);
$notice = "حُدِّث $key.";
} else {
$error = 'تعذّرت الكتابة.';
}
}
} elseif ($action === 'rotate_db' && isset(ENV_DB_CREDENTIALS[$key])) {
[$notice, $error] = rotateDbPassword($envFile, $logFile, $env, $key);
} else {
$error = 'عملية غير معروفة أو مفتاح غير قابل للتعديل.';
}
$env = envRead($envFile); // إعادة القراءة بعد أي تغيير
}
/**
* ‏يدوّر كلمة مرور مستخدم قاعدة بيانات بالترتيب الصحيح.
*
* ‏الترتيب هو كل شيء: ALTER USER أولاً، ثم **تحقّق باتصال فعلي**، ثم
* ‏كتابة .env. لو كتبنا .env أولاً وفشل ALTER، لصار التطبيق يحمل كلمة
* ‏لا يقبلها الخادم — انقطاع كامل. ولو نجح ALTER وفشلت الكتابة، نرجع
* ‏كلمة المرور القديمة فوراً بدل ترك الطرفين متنافرين.
*
* ‏ولا تُلمس البيانات في أي خطوة: هذا تغيير في mysql.user لا غير.
*/
function rotateDbPassword(string $envFile, string $logFile, array $env, string $key): array
{
$map = ENV_DB_CREDENTIALS[$key];
$user = $env[$map['user']] ?? '';
$dbName = $env[$map['db']] ?? '';
$rootPass = $env['MYSQL_ROOT_PASSWORD'] ?? '';
$oldPass = $env[$key] ?? '';
if ($user === '' || $dbName === '' || $rootPass === '') {
return ['', "ينقص {$map['user']} أو {$map['db']} أو MYSQL_ROOT_PASSWORD."];
}
// ‏كلمة مرور بلا رموز تحتاج هروباً في الشل — hex آمنة في كل سياق.
$newPass = envGenerate(20);
$alter = sprintf(
"ALTER USER %s@'%%' IDENTIFIED BY %s; FLUSH PRIVILEGES;",
quoteIdent($user), quoteLiteral($newPass)
);
[$code] = runMysql($rootPass, $alter);
if ($code !== 0) {
auditLog($logFile, 'DB_ROTATE_FAIL_ALTER', $key);
return ['', 'فشل ALTER USER — لم يتغيّر شيء.'];
}
// ‏تحقّق: هل تعمل الكلمة الجديدة فعلاً؟ الثقة بأن ALTER نجح لا تكفي
// ‏(قد يكون المستخدم معرَّفاً بمضيف آخر مثلاً).
if (!verifyDbLogin($user, $newPass, $dbName)) {
runMysql($rootPass, sprintf(
"ALTER USER %s@'%%' IDENTIFIED BY %s; FLUSH PRIVILEGES;",
quoteIdent($user), quoteLiteral($oldPass)
));
auditLog($logFile, 'DB_ROTATE_ROLLBACK', $key);
return ['', 'الكلمة الجديدة لم تعمل — أُعيدت القديمة.'];
}
$backup = envWrite($envFile, [$key => $newPass]);
if (!$backup) {
runMysql($rootPass, sprintf(
"ALTER USER %s@'%%' IDENTIFIED BY %s; FLUSH PRIVILEGES;",
quoteIdent($user), quoteLiteral($oldPass)
));
auditLog($logFile, 'DB_ROTATE_ROLLBACK_WRITE', $key);
return ['', 'تعذّرت كتابة .env — أُعيدت كلمة المرور القديمة.'];
}
auditLog($logFile, 'DB_ROTATE', "$key ($user@$dbName)");
return ["دُوِّرت كلمة مرور $user. أعد تشغيل حاويات php الآن.", ''];
}
/** ‏هروب معرّف MySQL بالعلامة الخلفية. */
function quoteIdent(string $s): string
{
return '`' . str_replace('`', '``', $s) . '`';
}
/** ‏هروب سلسلة نصية لـ MySQL. */
function quoteLiteral(string $s): string
{
return "'" . addcslashes($s, "\\'\"\0\n\r\032") . "'";
}
/**
* ‏ينفّذ SQL في حاوية mysql.
*
* ‏كلمة المرور تمرّ عبر متغيّر بيئة لا وسيطاً في سطر الأوامر: الوسيط
* ‏يظهر في `ps` لأي مستخدم على الجهاز.
*/
function runMysql(string $rootPass, string $sql): array
{
$cmd = 'docker compose -f ' . escapeshellarg(dirname(__DIR__, 3) . '/docker/docker-compose.yml')
. ' exec -T -e MYSQL_PWD mysql mysql -uroot -e ' . escapeshellarg($sql) . ' 2>&1';
$descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$proc = proc_open($cmd, $descriptors, $pipes, null, ['MYSQL_PWD' => $rootPass]);
if (!is_resource($proc)) return [1, 'proc_open failed'];
$out = stream_get_contents($pipes[1]) . stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
return [proc_close($proc), $out];
}
/** ‏يتأكد أن الاعتماد الجديد يسجّل دخولاً فعلياً. */
function verifyDbLogin(string $user, string $pass, string $db): bool
{
$cmd = 'docker compose -f ' . escapeshellarg(dirname(__DIR__, 3) . '/docker/docker-compose.yml')
. ' exec -T -e MYSQL_PWD mysql mysql -u' . escapeshellarg($user)
. ' ' . escapeshellarg($db) . ' -e "SELECT 1" 2>&1';
$descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$proc = proc_open($cmd, $descriptors, $pipes, null, ['MYSQL_PWD' => $pass]);
if (!is_resource($proc)) return false;
stream_get_contents($pipes[1]);
stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
return proc_close($proc) === 0;
}
/** ‏حالة المفتاح بلا كشف قيمته. */
function keyState(array $env, string $name): string
{
if (!array_key_exists($name, $env)) return 'مفقود';
return envIsPlaceholder($env[$name]) ? 'نائب' : 'مضبوط';
}
/** ‏بصمة قصيرة تكفي للتمييز ولا تكفي للاستعمال. */
function keyFingerprint(array $env, string $name): string
{
$v = $env[$name] ?? '';
return $v === '' ? '—' : substr(hash('sha256', $v), 0, 8);
}
$csrf = $_SESSION['csrf'] ?? '';
?>
<!doctype html>
<html lang="ar" dir="rtl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>مفاتيح البيئة — سيرو</title>
<style>
:root { color-scheme: dark; }
body { font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
background:#12151a; color:#e6e9ef; margin:0; padding:2rem 1rem; }
.wrap { max-width: 940px; margin: 0 auto; }
h1 { font-size:1.4rem; margin:0 0 .3rem; }
.sub { color:#8b93a7; font-size:.85rem; margin-bottom:1.5rem; }
.card { background:#1a1f27; border:1px solid #2a3140; border-radius:12px;
padding:1.1rem 1.25rem; margin-bottom:1.25rem; }
.card h2 { font-size:1rem; margin:0 0 .25rem; }
.card .hint { color:#8b93a7; font-size:.8rem; margin:0 0 .9rem; line-height:1.6; }
table { width:100%; border-collapse:collapse; }
td, th { padding:.5rem .4rem; text-align:right; border-bottom:1px solid #232a35;
font-size:.88rem; vertical-align:middle; }
th { color:#8b93a7; font-weight:600; font-size:.78rem; }
code { font-family: ui-monospace, monospace; font-size:.82rem; }
.state { font-size:.75rem; padding:.15rem .5rem; border-radius:999px; }
.ok { background:#12341f; color:#5ed68b; }
.miss { background:#3a1f1f; color:#ff8b8b; }
.ph { background:#3a3320; color:#ffc861; }
button { background:#2d6cdf; color:#fff; border:0; border-radius:8px;
padding:.4rem .9rem; font-size:.82rem; cursor:pointer; font-family:inherit; }
button:hover { background:#3d7cef; }
button.warn { background:#8a3a2a; }
button.warn:hover { background:#a1462f; }
input[type=text], input[type=password] { background:#12151a; border:1px solid #2a3140;
color:#e6e9ef; border-radius:8px; padding:.45rem .6rem; font-family:inherit;
font-size:.85rem; width:100%; box-sizing:border-box; }
.frozen { opacity:.72; }
.banner { border-radius:10px; padding:.75rem 1rem; margin-bottom:1.25rem; font-size:.88rem; }
.banner.err { background:#3a1f1f; border:1px solid #6b3030; }
.banner.ok { background:#12341f; border:1px solid #2f6b45; }
.banner.danger { background:#33221a; border:1px solid #7a4426; line-height:1.7; }
form.inline { display:inline; }
.row-actions { display:flex; gap:.5rem; align-items:center; }
</style>
</head>
<body>
<div class="wrap">
<?php if (!$authed): ?>
<h1>مفاتيح البيئة</h1>
<p class="sub">وصول محلي فقط عبر نفق SSH.</p>
<?php if ($error): ?><div class="banner err"><?= htmlspecialchars($error) ?></div><?php endif; ?>
<div class="card">
<form method="post">
<input type="hidden" name="action" value="login">
<p class="hint">عبارة مرور اللوحة</p>
<input type="password" name="passphrase" autofocus autocomplete="off">
<p></p>
<button type="submit">دخول</button>
</form>
</div>
<?php else: ?>
<h1>مفاتيح البيئة</h1>
<p class="sub">
<code><?= htmlspecialchars($envFile) ?></code> ·
<a href="?logout=1" style="color:#8b93a7">خروج</a>
</p>
<?php if ($notice): ?><div class="banner ok"><?= htmlspecialchars($notice) ?></div><?php endif; ?>
<?php if ($error): ?><div class="banner err"><?= htmlspecialchars($error) ?></div><?php endif; ?>
<div class="banner danger">
<strong>بعد أي تغيير</strong> — أعد تشغيل الحاويات لتقرأ الجديد:
<code>docker compose restart php php_food php_transit</code><br>
وتذكّر أن <code>WEBHOOK_SECRET_KEY</code> له نظير في تطبيق
<code>sms_relay</code> يحتاج إعادة بناء، و<code>S2S_SHARED_KEY</code>
و<code>INTERNAL_SOCKET_KEY</code> لهما نظائر في خوادم السوكيت والدفع.
</div>
<!-- ── أسرار داخلية ── -->
<div class="card">
<h2>أسرار داخلية</h2>
<p class="hint">طرفاها كلاهما لنا. التدوير آمن — يكفي إعادة تشغيل ما يحملها.</p>
<table>
<tr><th>المفتاح</th><th>الحالة</th><th>بصمة</th><th></th></tr>
<?php foreach (ENV_GENERATED as $name => $bytes):
$s = keyState($env, $name);
$cls = $s === 'مضبوط' ? 'ok' : ($s === 'نائب' ? 'ph' : 'miss'); ?>
<tr>
<td><code><?= htmlspecialchars($name) ?></code></td>
<td><span class="state <?= $cls ?>"><?= $s ?></span></td>
<td><code><?= keyFingerprint($env, $name) ?></code></td>
<td>
<form method="post" class="inline">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
<input type="hidden" name="action" value="rotate">
<input type="hidden" name="key" value="<?= htmlspecialchars($name) ?>">
<button type="submit"><?= $s === 'مضبوط' ? 'تدوير' : 'توليد' ?></button>
</form>
</td>
</tr>
<?php endforeach; ?>
</table>
</div>
<!-- ── مرتبطة بالجلسات ── -->
<div class="card">
<h2>مرتبطة بالجلسات</h2>
<p class="hint">
تدويرها يُخرج <strong>كل</strong> السائقين والركّاب من حساباتهم ويطالبهم
بتسجيل دخول جديد. ليس عطلاً، لكنه ليس شيئاً يُفعل وقت الذروة.
</p>
<table>
<tr><th>المفتاح</th><th>الحالة</th><th>بصمة</th><th></th></tr>
<?php foreach (ENV_SESSION_SCOPED as $name => $bytes):
$s = keyState($env, $name);
$cls = $s === 'مضبوط' ? 'ok' : ($s === 'نائب' ? 'ph' : 'miss'); ?>
<tr>
<td><code><?= htmlspecialchars($name) ?></code></td>
<td><span class="state <?= $cls ?>"><?= $s ?></span></td>
<td><code><?= keyFingerprint($env, $name) ?></code></td>
<td>
<form method="post" class="inline"
onsubmit="return confirm('سيُخرج هذا كل المستخدمين من حساباتهم. متابعة؟')">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
<input type="hidden" name="action" value="rotate">
<input type="hidden" name="key" value="<?= htmlspecialchars($name) ?>">
<button type="submit" class="warn">تدوير</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</table>
</div>
<!-- ── قواعد البيانات ── -->
<div class="card">
<h2>كلمات مرور قواعد البيانات</h2>
<p class="hint">
التدوير يجري بالترتيب الصحيح: <code>ALTER USER</code> ثم تحقّق باتصال
فعلي ثم كتابة <code>.env</code> — وإن فشلت أي خطوة تُستعاد الكلمة
القديمة فوراً. <strong>لا مساس بالبيانات ولا إعادة بناء</strong>؛ هذا
تغيير في <code>mysql.user</code> لا غير.
</p>
<table>
<tr><th>المفتاح</th><th>المستخدم</th><th>القاعدة</th><th>الحالة</th><th></th></tr>
<?php foreach (ENV_DB_CREDENTIALS as $name => $map):
$s = keyState($env, $name);
$cls = $s === 'مضبوط' ? 'ok' : ($s === 'نائب' ? 'ph' : 'miss'); ?>
<tr>
<td><code><?= htmlspecialchars($name) ?></code></td>
<td><code><?= htmlspecialchars($env[$map['user']] ?? '—') ?></code></td>
<td><code><?= htmlspecialchars($env[$map['db']] ?? '—') ?></code></td>
<td><span class="state <?= $cls ?>"><?= $s ?></span></td>
<td>
<?php if ($s === 'مضبوط'): ?>
<form method="post" class="inline"
onsubmit="return confirm('تدوير كلمة مرور القاعدة. أعد تشغيل حاويات php فوراً بعدها. متابعة؟')">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
<input type="hidden" name="action" value="rotate_db">
<input type="hidden" name="key" value="<?= htmlspecialchars($name) ?>">
<button type="submit" class="warn">تدوير</button>
</form>
<?php else: ?><span style="color:#8b93a7">—</span><?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</table>
</div>
<!-- ── طرف ثالث ── -->
<div class="card">
<h2>مفاتيح طرف ثالث</h2>
<p class="hint">لا تُولَّد — تُنسخ من لوحة المزوّد وتُلصق هنا.</p>
<table>
<tr><th>المفتاح</th><th>الحالة</th><th style="width:45%">قيمة جديدة</th><th></th></tr>
<?php foreach (ENV_EXTERNAL as $name):
$s = keyState($env, $name);
$cls = $s === 'مضبوط' ? 'ok' : ($s === 'نائب' ? 'ph' : 'miss'); ?>
<tr>
<form method="post">
<td><code><?= htmlspecialchars($name) ?></code></td>
<td><span class="state <?= $cls ?>"><?= $s ?></span></td>
<td>
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
<input type="hidden" name="action" value="set">
<input type="hidden" name="key" value="<?= htmlspecialchars($name) ?>">
<input type="text" name="value" autocomplete="off" placeholder="اتركه فارغاً لعدم التغيير">
</td>
<td><button type="submit">حفظ</button></td>
</form>
</tr>
<?php endforeach; ?>
</table>
</div>
<!-- ── المجمّدة ── -->
<div class="card frozen">
<h2>⛔ مفاتيح مجمّدة — للعلم فقط</h2>
<p class="hint">
بيانات مخزَّنة في القاعدة مشتقّة من هذه المفاتيح: أسماء الركّاب
وهواتفهم ولوحات السيارات مشفّرة بـ <code>ENC_KEY</code>، وأعمدة
<code>*_bidx</code> محسوبة بـ <code>BLIND_INDEX_PEPPER</code>.
تغيير أيٍّ منها <strong>لا يعيد تشفير شيء</strong> — يجعل كل صف قديم
غير قابل للقراءة إلى الأبد، ولا نسخة احتياطية تنفع لأنها هي نفسها
مشفّرة بالمفتاح القديم. تدويرها ترحيلُ بيانات لا تغييرُ إعداد،
ولذلك لا يوجد لها زر هنا ولا مسار يكتبها في هذا الملف.
</p>
<table>
<tr><th>المفتاح</th><th>الحالة</th><th>بصمة</th></tr>
<?php foreach (ENV_FROZEN as $name):
$s = keyState($env, $name);
$cls = $s === 'مضبوط' ? 'ok' : ($s === 'نائب' ? 'ph' : 'miss'); ?>
<tr>
<td><code><?= htmlspecialchars($name) ?></code></td>
<td><span class="state <?= $cls ?>"><?= $s ?></span></td>
<td><code><?= keyFingerprint($env, $name) ?></code></td>
</tr>
<?php endforeach; ?>
</table>
</div>
<p class="sub">سجلّ التغييرات: <code>backend/tools/panel/panel_audit.log</code></p>
<?php endif; ?>
</div>
</body>
</html>