في مقارنة مديري كلمات المرور Password Managers وصلنا إلى أن Vaultwarden هو الخيار المناسب لفريق صغير أو متوسط معه من يشغل سيرفراً، لأنه يعطيك المؤسسات والفرق والسياسات والوصول الطارئ دون ترخيص، والموظف يستخدم تطبيقات Bitwarden التي يعرفها. وفي هذا الدليل سوف نثبته كما يثبت في شركة حقيقية، ثم نرحل إليه ملف Excel لكلمات المرور كما وصفنا العملية في إدارة كلمات المرور في المؤسسة، ثم نختبر النسخة الاحتياطية باستعادة حقيقية، ونكسر الأشياء التي تنكسر عادة في الإنتاج.
المواضيع في السلسلة:
- إدارة كلمات المرور في المؤسسة: من ملف Excel إلى خزنة
- Bitwarden أم Vaultwarden أم Passbolt؟ مدير كلمات مرور لفريقك
- Vaultwarden: مدير كلمات مرور للفريق على سيرفرك (الموضوع الحالي)
وفي هذا المقال سوف نتناول:
- لماذا تحتاج الخزنة HTTPS حتى في الشبكة الداخلية، وماذا ترى إذا فتحتها عبر HTTP.
- التثبيت بـ Docker Compose بإصدار مثبت خلف Caddy مع جهة إصدار داخلية، ورمز صفحة الإدارة بصيغة Argon2، والبريد، وإغلاق التسجيل المفتوح.
- المؤسسة والمجموعات Collections والفرق Groups، ودعوة الموظفين وتأكيدهم.
- ترحيل ملف Excel حقيقي الشكل إلى صيغة CSV التي يقبلها Bitwarden، واستيراده بسطر الأوامر الرسمي.
- فرض التحقق الثنائي على الأعضاء، والوصول الطارئ، وسجل الأحداث، وتحديد المحاولات.
- النسخ الاحتياطي واختبار الاستعادة، ثم كسر الاستعادة بثلاث طرق، والتحديثات ووضع SSO.
شكل التجربة
سوف نبني كل شيء على جهاز واحد بأسماء مصطنعة تحت النطاق lab.test، وكل Container يبدأ اسمه بـ lab-pw-، وكل منفذ على 127.0.0.1 فقط. والصورة التالية تبين المكونات:

- Vaultwarden 1.37.4: آخر إصدار وقت الكتابة، بقاعدة SQLite الافتراضية، وهي تكفي لفريق بالعشرات أو المئات.
- Caddy 2.11.7: الـ Reverse Proxy الذي ينهي TLS، بشهادة من جهة إصدار داخلية لأن الاسم داخلي، وقد شرحنا Caddy في دليل تثبيته، والشهادات وجهات الإصدار في الشهادات الرقمية.
- Mailpit 1.31.5: سيرفر بريد للتجربة يستقبل كل الرسائل ويعرضها، بدلاً من بريد حقيقي.
- Container للأدوات: Node.js 24.21.0 مع سطر أوامر Bitwarden الرسمي 2026.9.1، لأننا لا نثبت npm على الجهاز نفسه.
لماذا لا تعمل الخزنة Vault عبر HTTP؟
قد تبدو العملية من الوهلة الأولى بسيطة: سيرفر داخلي في شبكة الشركة، فلماذا لا نفتحه عبر HTTP ونوفر على أنفسنا الشهادات؟ والإجابة أن الخزنة في المتصفح Web Vault تشفر وتفك كل شيء داخل المتصفح بواجهة Web Crypto، والمتصفحات لا تعطي هذه الواجهة إلا للصفحات في سياق آمن Secure Context، أي HTTPS، أو localhost. ولذلك تقول صفحة المشروع إن الخزنة تحتاج HTTPS. وقد فتحنا المنفذ 80 للـ Container مباشرة على المنفذ 18081 لهذه التجربة فقط، ثم فتحنا الخزنة من Chrome على http://vw.lab.test:18081/ بعد أن وجهنا الاسم إلى الجهاز، وسألنا الصفحة عن حالتها:
secure? false subtle? falseأي أن window.isSecureContext قيمته false وcrypto.subtle غير موجود، والصفحة نفسها تعرض الرسالة التالية:

والأمر نفسه في سطر أوامر Bitwarden، فعندما وجهناه لاحقاً إلى عنوان HTTP رفض الاتصال قبل أن يرسل أي شيء:
Unable to fetch ServerConfig from http://lab-pw-vw-r/api InsecureUrlNotAllowedError: Insecure URL not allowed. All URLs must use HTTPS.لذلك لا تحاول الالتفاف على ذلك، فحتى لو فتحت الخزنة Vault من localhost عبر نفق SSH، فإن تطبيقات الجوال وإضافات المتصفح لدى الموظفين تحتاج عنواناً بشهادة يثقون بها. وفي الشبكة الداخلية لديك طريقان: اسم تحت نطاقك العام بشهادة Let's Encrypt عبر التحقق بالـ DNS، أو جهة إصدار داخلية تثبت شهادتها الرئيسية على أجهزة الموظفين، وهو ما نفعله في التجربة.
التثبيت بـ Docker Compose
رمز صفحة الإدارة بصيغة Argon2
صفحة الإدارة /admin في Vaultwarden تحمي نفسها برمز واحد هو ADMIN_TOKEN، ومن يملكه يستطيع أن يدعو مستخدمين ويحذفهم ويلغي تحققهم الثنائي، ولذلك لا تضعه نصاً صريحاً في ملف الإعداد، وإنما ضع هاش Argon2id له بصيغة PHC كما في صفحة الويكي. وDocker Image نفسها تحمل أداة الهاش، والخيار --preset owasp يستخدم الحد الأدنى الذي توصي به OWASP:
docker run --rm -it vaultwarden/server:1.37.4 /vaultwarden hash --preset owaspوالمخرج سوف يكون كما يلي، بعد أن تكتب الرمز مرتين:
Generate an Argon2id PHC string using the 'owasp' preset:
Password:
Confirm Password:
ADMIN_TOKEN='$argon2id$v=19$m=19456,t=2,p=1$2Rdk3wkrNeiO0lkbA8ZG3AKLQfh6ZP8H2o4o+c0XiUs$6NkNQBRCA/PentPIOj1t4jncZI4hNQ5ouy3GdxPx/jw'
Generation of the Argon2id PHC string took: 81.009646msويجدر الإشارة هنا إلى أن الأداة تقرأ الرمز من طرفية TTY، ولأن تجربتنا تعمل من سكربت دون طرفية فقد شغلنا الأمر نفسه عبر الأداة script التي تعطيه طرفية، والمخرج أعلاه هو مخرجها كما هو. والهاش أعلاه لرمز التجربة فقط، فولد رمزك أنت. والآن نضع السطر في ملف .env بجانب ملف Compose، والعلامات المفردة حوله مهمة، لأنها تمنع Compose من تفسير علامات $ داخل الهاش:
VAULTWARDEN_ADMIN_TOKEN='$argon2id$v=19$m=19456,t=2,p=1$2Rdk3wkrNeiO0lkbA8ZG3AKLQfh6ZP8H2o4o+c0XiUs$6NkNQBRCA/PentPIOj1t4jncZI4hNQ5ouy3GdxPx/jw'ملف Compose وملف Caddy
ملف Compose التالي يشغل الثلاثة، والإصدارات كلها مثبتة:
name: lab-pw
services:
vaultwarden:
image: vaultwarden/server:1.37.4
container_name: lab-pw-vw
restart: unless-stopped
environment:
DOMAIN: "https://vw.lab.test:18443"
ADMIN_TOKEN: ${VAULTWARDEN_ADMIN_TOKEN}
SIGNUPS_ALLOWED: "false"
INVITATIONS_ALLOWED: "true"
ORG_CREATION_USERS: "[email protected]"
ORG_GROUPS_ENABLED: "true"
ORG_EVENTS_ENABLED: "true"
EVENTS_DAYS_RETAIN: "365"
PASSWORD_HINTS_ALLOWED: "false"
SMTP_HOST: "lab-pw-mailpit"
SMTP_PORT: "1025"
SMTP_SECURITY: "off"
SMTP_FROM: "[email protected]"
SMTP_FROM_NAME: "Lab Vault"
volumes:
- lab-pw-vw-data:/data
ports:
- "127.0.0.1:18081:80" # direct HTTP, only to show why the web vault needs HTTPS
networks: [lab-pw-net]
caddy:
image: caddy:2.11.7-alpine
container_name: lab-pw-caddy
restart: unless-stopped
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- lab-pw-caddy-data:/data
ports:
- "127.0.0.1:18443:18443"
networks:
lab-pw-net:
aliases: [vw.lab.test, vw-restore.lab.test] # the second name is only for the restore test
mailpit:
image: axllent/mailpit:v1.31.5
container_name: lab-pw-mailpit
restart: unless-stopped
ports:
- "127.0.0.1:18025:8025"
networks: [lab-pw-net]
networks:
lab-pw-net:
name: lab-pw-net
volumes:
lab-pw-vw-data:
name: lab-pw-vw-data
lab-pw-caddy-data:
name: lab-pw-caddy-dataفي الإعداد أعلاه لاحظ التالي:
DOMAINهو العنوان الذي يراه الموظف، بالمنفذ نفسه، والسيرفر يبني منه روابط الدعوات في البريد، فإذا كان خطأ وصلت الدعوات بروابط لا تعمل.SIGNUPS_ALLOWED: "false"يغلق التسجيل المفتوح، وINVITATIONS_ALLOWED: "true"يسمح لمديري المؤسسات بدعوة الموظفين، فلا يدخل أحد إلا بدعوة، وسوف نرى في قسم الكسر ماذا يحدث إذا نسيت السطر الأول.ORG_CREATION_USERSيحصر إنشاء المؤسسات في حساب فريق تقنية المعلومات، حتى لا ينشئ كل موظف مؤسسته الخاصة ويشارك فيها حسابات العمل بعيداً عن الإدارة.ORG_GROUPS_ENABLEDيفعل الفرق Groups، وهي معطلة افتراضياً وموصوفة في ملف الإعدادات بأنها ميزة تجريبية، وORG_EVENTS_ENABLEDيفعل سجل الأحداث مع حفظه 365 يوماً.PASSWORD_HINTS_ALLOWED: "false"يمنع تلميحات كلمة المرور، وهذا ما تطلبه NIST SP 800-63B-4 كما ذكرنا في المقال الأول.- المنفذ
18081المفتوح مباشرة على Vaultwarden هو لتجربة HTTP أعلاه فقط، فاحذفه في الإنتاج حتى لا يتجاوز أحد الـ Reverse Proxy. - الاسم الثاني
vw-restore.lab.testعلى Caddy هو لاختبار الاستعادة في آخر المقال.
وملف Caddy كما يلي:
{
skip_install_trust
}
vw.lab.test:18443 {
tls internal
encode zstd gzip
reverse_proxy lab-pw-vw:80 {
header_up X-Real-IP {remote_host}
}
}
# restore test: the restored copy, same internal CA
vw-restore.lab.test:18444 {
tls internal
reverse_proxy lab-pw-vw-r:80
}في الإعداد أعلاه لاحظ أن tls internal يجعل Caddy يصدر الشهادة من جهة إصدار محلية ينشئها بنفسه، وأن skip_install_trust يمنعه من محاولة تثبيتها في مخزن الثقة داخل الـ Container، والسطر header_up X-Real-IP {remote_host} يرسل عنوان الكلاينت الحقيقي إلى Vaultwarden، لأن Vaultwarden يقرأ هذه الترويسة افتراضياً، ويستخدم العنوان في تحديد المحاولات وفي السجلات كما سوف نرى. والجزء الثاني من الملف هو لاختبار الاستعادة.
نشغل التجربة، ثم ننظر في سجل Vaultwarden:
docker compose up -d
docker logs lab-pw-vwوالمخرج سوف يكون كما يلي:
/--------------------------------------------------------------------\
| Starting Vaultwarden |
| Version 1.37.4 |
|--------------------------------------------------------------------|
| This is an *unofficial* Bitwarden implementation, DO NOT use the |
| official channels to report bugs/features, regardless of client. |
| Send usage/configuration questions or feature requests to: |
| https://github.com/dani-garcia/vaultwarden/discussions or |
| https://vaultwarden.discourse.group/ |
| Report suspected bugs/issues in the software itself at: |
| https://github.com/dani-garcia/vaultwarden/issues/new |
\--------------------------------------------------------------------/
[2026-10-14 07:43:49.266][vaultwarden::auth][INFO] Private key 'data/rsa_key.pem' created correctly
[2026-10-14 07:43:52.286][start][INFO] Rocket has launched from http://0.0.0.0:80لاحظ السطر الذي ينشئ rsa_key.pem في أول تشغيل، فهذا الملف سوف نعود إليه في النسخ الاحتياطي، ولاحظ أن السجل لم يحذر من رمز إدارة نصي، لأننا وضعنا هاش Argon2.
الثقة بجهة الإصدار الداخلية
نأخذ الشهادة الرئيسية لجهة الإصدار من Caddy، وهي التي تثبتها على أجهزة الموظفين بأداة إدارة الأجهزة، ونعطيها لـ Container الأدوات:
docker cp lab-pw-caddy:/data/caddy/pki/authorities/local/root.crt ./work/lab-root.crt
openssl x509 -in work/lab-root.crt -noout -subject -enddateوالمخرج سوف يكون كما يلي:
subject=CN=Caddy Local Authority - 2026 ECC Root
notAfter=Aug 22 07:43:48 2036 GMTثم نشغل Container الأدوات على شبكة التجربة، ونثبت فيه سطر أوامر Bitwarden، ونخبر Node.js بالشهادة عبر المتغير NODE_EXTRA_CA_CERTS كما يطلب توثيق سطر الأوامر:
docker run -d --name lab-pw-cli --network lab-pw-net -v "$(pwd)/work:/work" -w /work \
-e NODE_EXTRA_CA_CERTS=/work/lab-root.crt node:24.21.0-alpine sleep infinity
docker exec lab-pw-cli sh -c "apk add --no-cache curl jq >/dev/null && npm install -g @bitwarden/[email protected] && bw --version"
docker exec lab-pw-cli sh -c "curl -sS --cacert /work/lab-root.crt -o /dev/null -w '%{http_code} verify=%{ssl_verify_result}\n' https://vw.lab.test:18443/"والمخرج سوف يكون كما يلي، بعد سطور npm:
2026.9.1
200 verify=0والقيمة verify=0 تعني أن الشهادة تحققت بالشهادة الرئيسية الداخلية دون أي تجاوز.
الدعوة بدلاً من التسجيل
قبل أن ننشئ أي حساب نتأكد أن التسجيل مغلق، فنحاول أن نسجل بريداً من خارج الشركة عبر نقطة التسجيل التي تستخدمها التطبيقات:
curl -sS --cacert /work/lab-root.crt -X POST https://vw.lab.test:18443/identity/accounts/register/send-verification-email \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","name":"Stranger","receiveMarketingEmails":false}' -w '\nHTTP %{http_code}\n'والمخرج سوف يكون كما يلي:
{"message":"Registration not allowed or user already exists","validationErrors":{"":["Registration not allowed or user already exists"]},"errorModel":{"message":"Registration not allowed or user already exists","object":"error"},"error":"","error_description":"","exceptionMessage":null,"exceptionStackTrace":null,"innerExceptionMessage":null,"object":"error"}
HTTP 400والآن ندعو أول مستخدم، وهو حساب فريق تقنية المعلومات [email protected]، من صفحة الإدارة، فنسجل الدخول إليها بالرمز ثم نرسل الدعوة:
curl -sS --cacert /work/lab-root.crt -c adm.jar -o /dev/null -w 'login HTTP %{http_code}\n' \
-X POST https://vw.lab.test:18443/admin -d 'token=YOUR-ADMIN-TOKEN'
curl -sS --cacert /work/lab-root.crt -b adm.jar -X POST https://vw.lab.test:18443/admin/invite \
-H 'Content-Type: application/json' -d '{"email":"[email protected]"}' \
| jq -c '{email: .email, status: ._status, created: .creationDate}'والمخرج سوف يكون كما يلي:
login HTTP 200
{"email":"[email protected]","status":1,"created":"2026-10-14T07:48:21.105617Z"}ووصلت إلى Mailpit رسالة «Join Vaultwarden» من [email protected] برابط يبدأ بالعنوان الصحيح https://vw.lab.test:18443/#/accept-organization/، وفيه توكن موقع بالمفتاح rsa_key.pem. وعندما فتحنا الرابط في المتصفح أخذنا إلى صفحة «Join Vaultwarden» التي تطلب كلمة المرور الرئيسية مرتين، بحد أدنى 12 حرفاً كما تقول الصفحة، مع خانة لفحص كلمة المرور في قواعد التسريبات، وقد ألغيناها في التجربة حتى لا يخرج أي طلب إلى خدمة خارجية. وبعد إنشاء الحساب ظهرت رسالة «Successfully accepted your invitation».
المؤسسة والمجموعات والفرق
في Bitwarden وVaultwarden تبنى الصلاحيات من ثلاثة أشياء: المؤسسة Organization التي تملك العناصر، والمجموعات Collections التي تقسم العناصر، والفرق Groups التي تجمع الأعضاء وتعطيهم صلاحية على المجموعات. والصورة التالية تبين ما بنيناه في التجربة:

أنشأنا المؤسسة «Lab Company» من الخزنة في المتصفح Web Vault بحساب it-admin، وهو الحساب الوحيد المسموح له بإنشاء مؤسسة. وأما المجموعات فلم ننشئها بأيدينا، لأن الاستيراد في القسم التالي ينشئها من عمود collections في ملف CSV. وبعد الاستيراد أنشأنا من صفحة الفرق ثلاثة فرق، لكل فريق مجموعته وصلاحيته:
| الفريق Group | المجموعة Collection | الصلاحية | لماذا |
|---|---|---|---|
| IT | IT | Manage collection | فريق تقنية المعلومات يضيف ويحذف أنظمته ويعطي الوصول |
| Finance | المالية | Edit items | المالية تحدث كلمات مرورها، ولا تدير المجموعة |
| Marketing | التسويق | Edit items | التسويق يحدث حسابات المنصات عند تغييرها |
وفي قائمة الصلاحيات خمسة خيارات:
- الخيار
View items: يرى العناصر ويستخدمها. - الخيار
View items, hidden passwords: يستخدم العناصر دون أن تظهر له كلمات المرور. - الخيار
Edit items: يرى العناصر ويعدلها ويضيف إليها. - الخيار
Edit items, hidden passwords: يعدل العناصر دون أن تظهر له كلمات المرور. - الخيار
Manage collection: يدير المجموعة نفسها ومن يصل إليها.
والخيار «مع إخفاء كلمات المرور» Hidden Passwords يسمح للعضو أن يملأ كلمة المرور في المتصفح دون أن يراها، كما في توثيق صلاحيات المجموعات، وهو مناسب لحسابات مثل منصات التواصل التي يستخدمها موظف مؤقت. ولكن الصفحة نفسها تحذر من أن الإخفاء يصعب النسخ واللصق ولا يمنع الوصول تماماً، فتعامل مع كلمة المرور المخفية على أنها معروفة لمن يستخدمها، وغيرها عند مغادرته. والصورة التالية تبين الفرق بعد إنشائها:

ثم أرسلنا دعوات إلى ثلاثة موظفين من صفحة الأعضاء Members بدور User، وأضفنا كل واحد إلى فريقه من تبويب Groups في نافذة الدعوة: sami إلى IT، وnora إلى Finance، وomar إلى Marketing. وبعد أن قبل كل منهم الدعوة من بريده وأنشأ كلمة مروره الرئيسية، بقيت حالته «Accepted»، لأن مفتاح المؤسسة لا يصله حتى يؤكده المالك. والتأكيد ممكن من الخزنة في المتصفح Web Vault، أو من سطر الأوامر بحساب المالك كما فعلنا:
ORG=bb32980a-492d-46d4-9ba2-b0cd996ad725
bw list org-members --organizationid $ORG | jq -r '.[] | [.email, .status, .type] | @tsv'
for id in $(bw list org-members --organizationid $ORG | jq -r '.[] | select(.status==1) | .id'); do
bw confirm org-member $id --organizationid $ORG; echo
done
bw list org-members --organizationid $ORG | jq -r '.[] | [.email, .status, .type] | @tsv'والمخرج سوف يكون كما يلي:
[email protected] 2 0
[email protected] 1 2
[email protected] 1 2
[email protected] 1 2
[email protected] 2 0
[email protected] 2 2
[email protected] 2 2
[email protected] 2 2والحالة 1 تعني قبل الدعوة Accepted، و2 تعني تم التأكيد Confirmed، والنوع 0 مالك Owner و2 مستخدم User. وقد يتساءل البعض: لماذا هذه الخطوة الإضافية؟ والإجابة أن السيرفر لا يملك مفتاح المؤسسة، فالمالك هو من يشفره بالمفتاح العام للعضو الجديد، والتأكيد هو اللحظة التي يحدث فيها ذلك. وفي الخزنة بالمتصفح Web Vault يعرض التأكيد عبارة بصمة Fingerprint Phrase للمفتاح العام، وإذا أردت أن تتأكد أنك تعطي المفتاح للشخص الصحيح، فاطلب من الموظف عبر الهاتف أن يقرأ عليك العبارة من حسابه، والصورة التالية من تأكيد الوصول الطارئ Emergency Access لاحقاً، وفيها العبارة نفسها:

ترحيل ملف Excel إلى الخزنة Vault
الآن نصل إلى ما بدأت به السلسلة: ملف Excel لكلمات المرور. صنعنا ملفاً مصطنعاً يشبه ما تجده في الشركات، بورقة لكل قسم: IT والمالية والتسويق، وفي أعلى كل ورقة عنوان مدمج «سري، لا ترسل الملف»، ثم سطر فارغ، ثم العناوين في السطر الثالث، وفيه المشاكل المعتادة: صفان لنفس الجدار الناري بكلمتي مرور مختلفتين، وكلمة مرور مكتوبة رقماً فحفظها Excel رقماً، وصف دون كلمة مرور لأنها «عند نورة»، وعنوان SSH بدلاً من رابط. والجدول التالي يبين ورقة IT كما صنعناها:
| النظام | الرابط | اسم المستخدم | كلمة المرور | ملاحظات | آخر تغيير | المسؤول |
|---|---|---|---|---|---|---|
| الجدار الناري OPNsense | https://fw.lab.test | root | Fw#2023! | 2023-03-02 | سامي | |
| الجدار الناري OPNsense | https://fw.lab.test | root | Fw#2024! | الجديدة | 2024-09-15 | سامي |
| Proxmox | https://pve.lab.test:8006 | root@pam | Pve-Lab-77 | 2025-01-10 | سامي | |
| التخزين TrueNAS | https://nas.lab.test | admin | admin123 | لم تتغير منذ التركيب | 2022-06-01 | |
| السويتش الرئيسي | ssh 10.0.0.2 | admin | Sw!tch-01 | سامي | ||
| شبكة الضيوف Wi-Fi | Welcome2024 | مكتوبة على اللوحة في الاستقبال | 2024-01-07 | |||
| مسجل النطاق | https://registrar.example.com | [email protected] | Dns-Reg-2025$ | رمز التحقق يصل إلى جوال سامي | 2025-02-20 | سامي |
| لوحة البريد | https://mail-admin.example.com | [email protected] | Mail@Admin9 | 2025-05-04 | سامي |
lab/make_sheet.py: السكربت الذي صنع الملف بأوراقه الثلاث
# Builds passwords.xlsx: a made-up but typical "shared passwords" workbook, one sheet per department.
# All names, hosts and passwords are invented for the lab (lab.test / example.com).
from datetime import date
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill
HEAD = ["النظام", "الرابط", "اسم المستخدم", "كلمة المرور", "ملاحظات", "آخر تغيير", "المسؤول"]
SHEETS = {
"IT": [
["الجدار الناري OPNsense", "https://fw.lab.test", "root", "Fw#2023!", "", date(2023, 3, 2), "سامي"],
["الجدار الناري OPNsense", "https://fw.lab.test", "root", "Fw#2024!", "الجديدة", date(2024, 9, 15), "سامي"],
["Proxmox", "https://pve.lab.test:8006", "root@pam", "Pve-Lab-77", "", date(2025, 1, 10), "سامي"],
["التخزين TrueNAS", "https://nas.lab.test", "admin", "admin123", "لم تتغير منذ التركيب", date(2022, 6, 1), ""],
["السويتش الرئيسي", "ssh 10.0.0.2", "admin", "Sw!tch-01", "", None, "سامي"],
["شبكة الضيوف Wi-Fi", "", "", "Welcome2024", "مكتوبة على اللوحة في الاستقبال", date(2024, 1, 7), ""],
["مسجل النطاق", "https://registrar.example.com", "[email protected]", "Dns-Reg-2025$", "رمز التحقق يصل إلى جوال سامي", date(2025, 2, 20), "سامي"],
["لوحة البريد", "https://mail-admin.example.com", "[email protected]", "Mail@Admin9", "", date(2025, 5, 4), "سامي"],
],
"المالية": [
["البنك (الخدمات المصرفية للشركات)", "https://bank.example.com/corporate", "LABCO001", "Bank-Lab#5521", "الرمز المؤقت على جوال المدير المالي", date(2025, 3, 1), "نورة"],
["نظام الرواتب", "https://payroll.example.com", "[email protected]", "Payroll2025", "", date(2025, 1, 1), "نورة"],
["بوابة المورد", "https://supplier.example.com", "labco", 4417, "رقم فقط", date(2024, 11, 3), "نورة"],
["بوابة الفوترة", "https://einvoice.example.com", "labco-einv", "", "كلمة المرور عند نورة", None, "نورة"],
],
"التسويق": [
["حساب الشركة على X", "https://x.example.com", "@labco", "Labco-Social-1", "يستخدمه ثلاثة موظفين", date(2024, 4, 12), "عمر"],
["إعلانات البحث", "https://ads.example.com", "[email protected]", "Ads!2025-lab", "", date(2025, 6, 30), "عمر"],
["النشرة البريدية", "https://news.lab.test/admin", "admin", "Newsletter#88", "", date(2025, 8, 8), "عمر"],
],
}
wb = Workbook()
wb.remove(wb.active)
for name, rows in SHEETS.items():
ws = wb.create_sheet(name)
ws.sheet_view.rightToLeft = True
ws["A1"] = "كلمات مرور " + name + " (سري، لا ترسل الملف)"
ws["A1"].font = Font(bold=True, size=14)
ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=len(HEAD))
ws.append([])
ws.append(HEAD)
for c in ws[3]:
c.font = Font(bold=True); c.fill = PatternFill("solid", fgColor="FFF2CC")
for r in rows:
ws.append(r)
for row in ws.iter_rows(min_row=4, min_col=6, max_col=6):
for c in row: c.number_format = "yyyy-mm-dd"
wb.save("passwords.xlsx")
print("passwords.xlsx:", {n: len(r) for n, r in SHEETS.items()})
من ورقة Excel إلى صيغة Bitwarden CSV
صيغة استيراد المؤسسة في توثيق Bitwarden ملف CSV عناوينه بالضبط كما يلي، وحالة الأحرف مهمة:
collections,type,name,notes,fields,reprompt,login_uri,login_username,login_password,login_totpوالحل البسيط هو أن تحفظ الورقة من Excel بصيغة CSV وتعيد تسمية الأعمدة، ولكن هذا ينقل كل مشاكل الملف كما هي إلى الخزنة Vault: الصفوف المكررة، والعنوان المدمج كأنه عنصر، والأرقام التي فقدت أصفارها. لذلك كتبنا سكربتاً صغيراً يقرأ كل ورقة، ويبحث عن سطر العناوين أينما كان، ويجعل اسم الورقة اسم المجموعة، ويطبع كل مشكلة يجدها حتى تصلحها قبل الاستيراد:
# Converts passwords.xlsx (one sheet per department) to a Bitwarden organization CSV.
# Each sheet becomes a collection; rows are checked and every problem is printed so you can fix it before import.
import csv, sys
from datetime import date, datetime
from openpyxl import load_workbook
SRC, OUT = sys.argv[1], sys.argv[2]
COLS = ["collections", "type", "name", "notes", "fields", "reprompt",
"login_uri", "login_username", "login_password", "login_totp"]
wb = load_workbook(SRC, data_only=True)
items, problems = {}, []
for ws in wb.worksheets:
rows = list(ws.iter_rows(values_only=True))
hdr_i = next(i for i, r in enumerate(rows) if r and "كلمة المرور" in r) # the header is not always row 1
hdr = {v: i for i, v in enumerate(rows[hdr_i]) if v}
for n, r in enumerate(rows[hdr_i + 1:], start=hdr_i + 2):
get = lambda k: r[hdr[k]] if hdr.get(k) is not None and hdr[k] < len(r) else None
system = get("النظام")
if not system:
continue
pw, user, uri = get("كلمة المرور"), get("اسم المستخدم") or "", get("الرابط") or ""
where = f"{ws.title}!{n}"
if pw in (None, ""):
problems.append(f"{where} {system}: no password in the sheet, ask the owner and add it by hand")
pw = ""
elif not isinstance(pw, str):
problems.append(f"{where} {system}: password stored as a number ({pw!r}), check leading zeros")
pw = str(pw)
if pw and len(pw) < 12:
problems.append(f"{where} {system}: {len(pw)} characters, rotate after import")
if uri and not uri.startswith("http"):
problems.append(f"{where} {system}: '{uri}' is not a URL, kept in notes")
uri_note, uri = f"Address: {uri}", ""
else:
uri_note = ""
changed = get("آخر تغيير")
notes = "\n".join(x for x in [
str(get("ملاحظات") or ""), uri_note,
f"Owner: {get('المسؤول')}" if get("المسؤول") else "",
f"Last changed (Excel): {changed:%Y-%m-%d}" if isinstance(changed, (date, datetime)) else "",
f"Imported from {SRC} {where}"] if x)
key = (ws.title, system, user)
row = {"collections": ws.title, "type": "login", "name": system, "notes": notes, "fields": "",
"reprompt": "1" if ws.title == "المالية" else "0",
"login_uri": uri, "login_username": user, "login_password": pw, "login_totp": "",
"_changed": changed if isinstance(changed, (date, datetime)) else date.min}
if key in items:
old = items[key]
keep, drop = (row, old) if row["_changed"] >= old["_changed"] else (old, row)
problems.append(f"{where} {system}: duplicate of another row, kept the one changed {keep['_changed']:%Y-%m-%d}")
items[key] = keep
else:
items[key] = row
with open(OUT, "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=COLS, extrasaction="ignore")
w.writeheader()
for it in items.values():
w.writerow(it)
print(f"{len(items)} items in {len({k[0] for k in items})} collections -> {OUT}")
print(f"{len(problems)} problems:")
for p in problems:
print(" -", p)في السكربت أعلاه لاحظ التالي:
- الصفوف المكررة لنفس النظام واسم المستخدم داخل الورقة نفسها يبقى منها الأحدث بحسب عمود «آخر تغيير»، ويطبع السكربت أنه حذف الآخر.
- العمود «المسؤول» وتاريخ آخر تغيير ومكان الصف في الملف تنتقل إلى الملاحظات، حتى تعرف لاحقاً من أين جاء كل عنصر ومن تسأل عنه.
- عناصر المالية تأخذ
reprompt=1، فيطلب التطبيق كلمة المرور الرئيسية مرة أخرى قبل أن يعرض كلمة المرور، وهذه طبقة صغيرة إضافية لحسابات البنك. - كل كلمة مرور أقصر من 12 حرفاً تظهر في القائمة، ليس لأن الاستيراد يرفضها، وإنما لأنها أول ما تغيره بعد الاستيراد.
نشغل السكربتين في Container من Python ونثبت openpyxl داخله:
docker run --rm --name lab-pw-py -v "$(pwd)/work:/work" -w /work python:3.13.16-slim sh -c \
"pip install --quiet --root-user-action=ignore --disable-pip-version-check openpyxl==3.1.5 &&
python make_sheet.py && python xlsx_to_bitwarden.py passwords.xlsx org-import.csv"والمخرج سوف يكون كما يلي:
passwords.xlsx: {'IT': 8, 'المالية': 4, 'التسويق': 3}
14 items in 3 collections -> org-import.csv
13 problems:
- IT!4 الجدار الناري OPNsense: 8 characters, rotate after import
- IT!5 الجدار الناري OPNsense: 8 characters, rotate after import
- IT!5 الجدار الناري OPNsense: duplicate of another row, kept the one changed 2024-09-15
- IT!6 Proxmox: 10 characters, rotate after import
- IT!7 التخزين TrueNAS: 8 characters, rotate after import
- IT!8 السويتش الرئيسي: 9 characters, rotate after import
- IT!8 السويتش الرئيسي: 'ssh 10.0.0.2' is not a URL, kept in notes
- IT!9 شبكة الضيوف Wi-Fi: 11 characters, rotate after import
- IT!11 لوحة البريد: 11 characters, rotate after import
- المالية!5 نظام الرواتب: 11 characters, rotate after import
- المالية!6 بوابة المورد: password stored as a number (4417), check leading zeros
- المالية!6 بوابة المورد: 4 characters, rotate after import
- المالية!7 بوابة الفوترة: no password in the sheet, ask the owner and add it by handوفي المخرج أعلاه لاحظ التالي:
- 15 صفاً أصبحت 14 عنصراً، لأن صفي الجدار الناري أصبحا عنصراً واحداً بكلمة المرور الأحدث.
- كلمة مرور بوابة المورد حفظها Excel رقماً، والسكربت حولها إلى نص ونبهك، لأن رقماً مثل
0441يصبح441في Excel دون أن تلاحظ. - بوابة الفوترة دون كلمة مرور، وهذه حالة «كلمة المرور في رأس شخص واحد» التي تحدثنا عنها في المقال الأول، والسكربت يتركها فارغة لتسأل صاحبها.
- عشرة تنبيهات بطول أقل من 12 حرفاً من 14 عنصراً، وهي قائمة التغيير بعد الاستيراد.
الاستيراد بسطر الأوامر
نسجل الدخول بحساب المالك داخل Container الأدوات، ونحفظ مفتاح الجلسة في المتغير BW_SESSION، ثم نستورد الملف إلى المؤسسة بالخيار --organizationid:
bw config server https://vw.lab.test:18443
export BW_SESSION=$(bw login [email protected] "$MASTER_PASSWORD" --raw)
bw list organizations | jq -c '.[] | {id, name}'
bw import --organizationid bb32980a-492d-46d4-9ba2-b0cd996ad725 bitwardencsv /work/org-import.csv
bw sync >/dev/null
bw list org-collections --organizationid bb32980a-492d-46d4-9ba2-b0cd996ad725 | jq -r '.[] | .name'
bw list items --organizationid bb32980a-492d-46d4-9ba2-b0cd996ad725 | jq lengthوالمخرج سوف يكون كما يلي، وقد فصلنا سطوره لأن سطر الأوامر يطبع بعض رسائله دون سطر جديد في آخرها:
Saved setting `config`.
{"id":"bb32980a-492d-46d4-9ba2-b0cd996ad725","name":"Lab Company"}
Imported /work/org-import.csv
Default collection
IT
التسويق
المالية
14الاستيراد أنشأ المجموعات الثلاث من عمود collections، و«Default collection» أنشأها Vaultwarden مع المؤسسة. والصورة التالية تبين العناصر في لوحة الإدارة بعد الاستيراد:

والسؤال الأهم: هل يرى كل موظف مجموعته فقط؟ سجلنا الدخول بكل واحد من الثلاثة في مجلد إعداد مستقل لسطر الأوامر، وعرضنا ما يراه مجمعاً بالمجموعة:
for u in sami nora omar; do
export BITWARDENCLI_APPDATA_DIR=/root/bw-$u
bw config server https://vw.lab.test:18443 >/dev/null
S=$(bw login [email protected] "Lab-$u-Master-Pass-2026!" --raw)
echo "== $u"; BW_SESSION=$S bw list items | jq -r 'group_by(.collectionIds[0]) | .[] | "\(length) items: " + ([.[].name] | join(", "))'
doneوالمخرج سوف يكون كما يلي، بعد حذف رسالتي إنشاء مجلد الإعداد اللتين يطبعهما سطر الأوامر لكل مستخدم:
== sami
7 items: Proxmox, التخزين TrueNAS, الجدار الناري OPNsense, السويتش الرئيسي, شبكة الضيوف Wi-Fi, لوحة البريد, مسجل النطاق
== nora
4 items: البنك (الخدمات المصرفية للشركات), بوابة الفوترة, بوابة المورد, نظام الرواتب
== omar
3 items: إعلانات البحث, النشرة البريدية, حساب الشركة على Xكل موظف يرى مجموعة فريقه فقط، وهذا هو الفرق الأول عن ملف Excel الذي يرى فيه كل من يفتحه كل الأوراق. والآن احذف الملف الأصلي ونسخه من المجلد المشترك والبريد، وابدأ بتغيير كلمات المرور التي في قائمة السكربت، كما في الخطوة الخامسة من العملية.
فرض التحقق الثنائي على الأعضاء
الخزنة Vault التي تفتح بكلمة مرور واحدة تعيدنا إلى درس حادثة Snowflake في المقال الأول، ولذلك نفرض التحقق الثنائي على كل أعضاء المؤسسة. فعلنا أولاً التطبيق المصادق Authenticator App للمالك وsami، ولم نفعله لـ nora وomar عمداً، ثم فتحنا السياسة «Require two-step login» من إعدادات المؤسسة:

وبعد الحفظ عرضنا حالة الأعضاء من سطر الأوامر مع عمود التحقق الثنائي:
[email protected] 2 0 true
[email protected] 2 2 true
[email protected] -1 2 false
[email protected] -1 2 falseوالحالة -1 تعني أن وصولهما سحب Revoked ولم يحذفا، ووصل إلى كل منهما بريد بعنوان «Your access to Lab Company has been revoked.» نصه:
Your user account has been removed from the *Lab Company* organization because you do not have two-step login configured.
Before you can re-join this organization you need to set up two-step login on your user account.
You can enable two-step login in your account settings.وعندما زامنت nora خزنتها Vault من سطر الأوامر أصبح عدد عناصرها صفراً:
Syncing complete.
0
وفي النتيجة أعلاه لاحظ التالي:
- التحذير في النافذة يقول إن العضو «سوف يزال»، ونص البريد يقول «has been removed»، ولكن الحالة الفعلية سحب الوصول، والعضو بقي في فريقه، وهذا أفضل، لأن إعادته لا تحتاج دعوة جديدة.
- بعد أن فعلت
noraالتطبيق المصادق، أعاد المالك وصولها من قائمة العضو بالخيار «Restore access»، فزامنت ووجدت عناصر المالية الأربعة كما كانت. - تسجيل الدخول بسطر الأوامر دون رمز أصبح يرفض، فالأمر
bw login [email protected] … --nointeractionرد بـCode is required.، ومع--method 0 --codeورمز من التطبيق المصادق رد بـYou are logged in!. - أبلغ قبل أن تفعل السياسة، وأعط الموظفين أسبوعاً لتفعيل التحقق الثنائي، لأن من لم يفعله يفقد وصوله إلى حسابات فريقه في اللحظة نفسها.
الوصول الطارئ Emergency Access
الوصول الطارئ Emergency Access يجعل شخصاً موثوقاً يطلب الوصول إلى خزنتك إذا غبت، فيحصل عليه بعد مدة انتظار تحددها أنت، أو قبلها إذا وافقت. وله نوعان بحسب توثيق Bitwarden: العرض View ويعطي قراءة كل عناصر خزنتك الشخصية، والاستيلاء Takeover ويجعله يضع كلمة مرور رئيسية جديدة لحسابك. ولاحظ تحذير الصفحة في Vaultwarden للمالك: إذا أعطيت Takeover لشخص وأنت مالك مؤسسة فسوف يحصل على صلاحياتك كمالك بعد الاستيلاء.
في التجربة أضاف المالك sami جهة اتصال طارئة بنوع View ومدة انتظار يومين، فوصل إلى sami بريد «Emergency access for [email protected]» فقبله، ثم أكده المالك بعبارة البصمة كما في الصورة السابقة، ثم طلب sami الوصول، فوصل إلى المالك البريد التالي:
[email protected] has initiated an emergency access request to View your account. You may login on the web vault (https://vw.lab.test:18443) and manually approve or reject this request.
If you do nothing, the request will automatically be approved after 2 day(s).وافق المالك على الطلب، ففتح sami الخزنة الطارئة، ووجد فيها عنصراً واحداً هو «Domain registrar recovery codes»، وهو الملاحظة الشخصية الوحيدة في خزنة المالك، ولم يجد أي عنصر من عناصر المؤسسة الأربعة عشر. وهذا أهم درس في هذا القسم: الوصول الطارئ Emergency Access يغطي الخزنة الشخصية فقط، أما حسابات المؤسسة فحمايتها من غياب شخص أن يكون للمؤسسة مالكان على الأقل، فأضف مالكاً ثانياً قبل أن تحتاجه.
سجل الأحداث وتحديد المحاولات
بعد تفعيل ORG_EVENTS_ENABLED تسجل المؤسسة كل دخول وكل عرض لعنصر وكل تغيير في الأعضاء والسياسات، والصورة التالية من صفحة Event logs في التجربة:

وفي السجل أعلاه لاحظ أن سطر الأوامر الذي يعرض قائمة العناصر يسجل «Viewed item» لكل عنصر، فـ nora عرضت أربعة عناصر في الثانية نفسها، فاقرأ السجل بهذا الفهم في أي تحقيق. وأما تحديد المحاولات فـ Vaultwarden يحد طلبات الدخول إلى صفحة الإدارة افتراضياً بثلاث محاولات متتالية، ثم محاولة كل 300 ثانية، بحسب ADMIN_RATELIMIT_MAX_BURST وADMIN_RATELIMIT_SECONDS في ملف الإعدادات. أرسلنا خمسة رموز خاطئة متتالية:
for i in 1 2 3 4 5; do
curl -s --cacert /work/lab-root.crt -o /dev/null -w "attempt $i: HTTP %{http_code}\n" \
-X POST https://vw.lab.test:18443/admin -d "token=wrong-guess-$i"
doneوالمخرج سوف يكون كما يلي:
attempt 1: HTTP 401
attempt 2: HTTP 401
attempt 3: HTTP 401
attempt 4: HTTP 429
attempt 5: HTTP 429وفي سجل Vaultwarden ظهرت المحاولات مع عنوان الكلاينت الحقيقي 192.168.32.5، وهو عنوان Container الأدوات وليس عنوان Caddy، بفضل الترويسة X-Real-IP:
[2026-10-14 12:23:09.302][vaultwarden::api::admin][ERROR] Invalid admin token. IP: 192.168.32.5
[2026-10-14 12:23:09.303][response][INFO] (post_admin_login) POST /admin/ application/x-www-form-urlencoded => 401 Unauthorized
[2026-10-14 12:23:09.351][request][INFO] POST /admin
[2026-10-14 12:23:09.396][vaultwarden::api::admin][ERROR] Invalid admin token. IP: 192.168.32.5
[2026-10-14 12:23:09.396][response][INFO] (post_admin_login) POST /admin/ application/x-www-form-urlencoded => 401 Unauthorizedوهذه السطور هي ما يقرؤه fail2ban ليحظر العنوان على مستوى الجدار الناري، وطريقة إعداده في صفحة الويكي. ولو لم يمرر الـ Reverse Proxy عنوان الكلاينت لظهر عنوان Caddy في كل سطر، فيحظر fail2ban الـ Proxy نفسه ويقطع الخزنة عن الجميع. ولاحظ أن ملاحظات الإصدار 1.37.4 غيرت طريقة قراءة X-Forwarded-For إذا استخدمته بدلاً من X-Real-IP، فأصبح العنوان المعتمد هو آخر عنوان من اليمين ليس من الـ Proxies الموثوقة في IP_HEADER_TRUSTED_PROXIES، فإذا كان أمام Vaultwarden أكثر من Proxy فأضفها كلها. وأفضل من الحظر ألا تكون صفحة الإدارة متاحة من الإنترنت أصلاً، فاحصرها في شبكة الإدارة من الـ Reverse Proxy.
النسخ الاحتياطي
كل بيانات Vaultwarden في المجلد /data، ولكن ليس كل ما فيه بالأهمية نفسها، ولا يصح نسخ قاعدة SQLite وهي تعمل بأمر نسخ عادي، والسبب أن جزءاً من آخر التغييرات يكون في ملف db.sqlite3-wal. والصورة التالية تبين ما تأخذه، وما يحدث إذا نسيت جزءاً، والنتائج فيها من اختبار الاستعادة أدناه:

وبحسب صفحة النسخ الاحتياطي في الويكي فقاعدة البيانات والمرفقات نسخها مطلوب، وrsa_key* وconfig.json نسخها موصى به، وsends وicon_cache اختياريان. ومنذ الإصدار 1.32.1 يحمل Vaultwarden أمراً ينسخ قاعدة SQLite بواجهة النسخ الآمن Online Backup API وهي تعمل. وأضفنا مرفقاً إلى عنصر الجدار الناري قبل النسخ، وهو ملف إعداد مصطنع اسمه fw-config-2026-10.xml، حتى نختبره في الاستعادة. والسكربت التالي يأخذ النسخة:
#!/bin/sh
# Backs up the Vaultwarden data volume: a consistent SQLite copy, the rsa key, attachments and config.json (if present).
set -eu
VOL=lab-pw-vw-data
OUT=${1:-./backups}
STAMP=$(date +%Y%m%d-%H%M%S)
mkdir -p "$OUT"
# 1. consistent copy of the live database (SQLite online backup API), written inside /data
DB=$(docker exec lab-pw-vw /vaultwarden backup | sed -n "s/.*'data\/\(db_[0-9_]*\.sqlite3\)'.*/\1/p")
# 2. pack the copy with the files that live outside the database
docker run --rm --name lab-pw-backup --entrypoint sh \
-v "$VOL":/data:ro -v "$(cd "$OUT" && pwd)":/backup \
vaultwarden/server:1.37.4 -c "
cd /data
FILES=\"$DB rsa_key.pem\"
[ -d attachments ] && FILES=\"\$FILES attachments\"
[ -d sends ] && FILES=\"\$FILES sends\"
[ -f config.json ] && FILES=\"\$FILES config.json\"
tar czf /backup/vaultwarden-$STAMP.tar.gz \$FILES
tar tzvf /backup/vaultwarden-$STAMP.tar.gz"
# 3. remove the temporary copy from the live volume
docker exec lab-pw-vw rm "/data/$DB"
echo "backup: $OUT/vaultwarden-$STAMP.tar.gz"في السكربت أعلاه لاحظ التالي:
- الأمر
/vaultwarden backupيكتب نسخة متسقة من قاعدة البيانات داخل/data، والسكربت يأخذ اسمها من مخرجه. - الـ Container المؤقت يركب الـ Volume للقراءة فقط
:ro، فلا يستطيع السكربت أن يغير البيانات الحية بالخطأ. - الأرشيف يحمل
rsa_key.pemورمز صفحة الإدارة إذا كان فيconfig.json، فشفر الأرشيف قبل أن ترسله إلى أي مكان خارج السيرفر، كما توصي صفحة الويكي.
والمخرج سوف يكون كما يلي:
-rw-r--r-- root/root 339968 2026-10-14 12:24 db_20261014_122432.sqlite3
-rw-r--r-- root/root 1679 2026-10-14 07:43 rsa_key.pem
drwxr-xr-x root/root 0 2026-10-14 12:23 attachments/
drwxr-xr-x root/root 0 2026-10-14 12:23 attachments/4a76e770-5811-4b91-8288-7d17e21d2008/
-rw-r--r-- root/root 113 2026-10-14 12:23 attachments/4a76e770-5811-4b91-8288-7d17e21d2008/84e65943fc84b17bc253
backup: ./backups/vaultwarden-20261014-152431.tar.gzولاحظ أن التاريخ في اسم الأرشيف بتوقيت الجهاز الذي شغل السكربت، والتاريخ داخل الـ Container بتوقيت UTC، ولذلك الفرق ثلاث ساعات. وشغل السكربت مرة كل يوم على الأقل، وانقل الأرشيف إلى مكان ثان، كما في قاعدة 3-2-1.
اختبار الاستعادة
النسخة التي لم تستعدها لا تعرف أنها تعمل، ولذلك نستعيدها إلى Volume جديد وسيرفر ثان بجانب الأصلي، ثم نسجل الدخول إليه ونقرأ العناصر والمرفق. والسكربت التالي يفك الأرشيف في Volume فارغ، ويجعل نسخة قاعدة البيانات db.sqlite3، ويحذف أي ملف WAL قديم كما تطلب صفحة الويكي:
#!/bin/sh
# Restores a backup archive into an empty volume: the database copy becomes db.sqlite3, no stale WAL file.
set -eu
ARCHIVE=$1
VOL=${2:-lab-pw-vw-restore}
DIR=$(cd "$(dirname "$ARCHIVE")" && pwd); FILE=$(basename "$ARCHIVE")
docker volume create "$VOL" >/dev/null
docker run --rm --name lab-pw-restore --entrypoint sh \
-v "$VOL":/data -v "$DIR":/backup:ro vaultwarden/server:1.37.4 -c "
cd /data
tar xzf /backup/$FILE
mv db_*.sqlite3 db.sqlite3
rm -f db.sqlite3-wal db.sqlite3-shm
ls -l /data"نشغله على الأرشيف:
sh restore.sh ./backups/vaultwarden-20261014-152431.tar.gz lab-pw-vw-restoreوالمخرج سوف يكون كما يلي:
total 340
drwxr-xr-x 3 root root 4096 Oct 14 12:23 attachments
-rw-r--r-- 1 root root 339968 Oct 14 12:24 db.sqlite3
-rw-r--r-- 1 root root 1679 Oct 14 07:43 rsa_key.pemثم نشغل سيرفراً ثانياً على الـ Volume المستعاد، خلف الاسم vw-restore.lab.test في Caddy، لأن سطر الأوامر يرفض HTTP كما رأينا:
docker run -d --name lab-pw-vw-r --network lab-pw-net -e DOMAIN=https://vw-restore.lab.test:18444 \
-e SIGNUPS_ALLOWED=false -e ORG_GROUPS_ENABLED=true -v lab-pw-vw-restore:/data vaultwarden/server:1.37.4ونسجل الدخول بحساب sami ورمزه من التطبيق المصادق، ونعرض عناصره، وننزل المرفق ونقرؤه:
export BITWARDENCLI_APPDATA_DIR=/root/bw-restore
bw config server https://vw-restore.lab.test:18444
export BW_SESSION=$(bw login [email protected] "$MASTER_PASSWORD" --method 0 --code "$TOTP_CODE" --raw)
bw list items | jq -r '.[].name'
ID=$(bw list items --search OPNsense | jq -r '.[0].id')
bw get attachment fw-config-2026-10.xml --itemid $ID --output /tmp/restored.xml
cat /tmp/restored.xmlوالمخرج سوف يكون كما يلي:
Saved setting `config`.
Proxmox
التخزين TrueNAS
الجدار الناري OPNsense
السويتش الرئيسي
شبكة الضيوف Wi-Fi
لوحة البريد
مسجل النطاق
Saved /tmp/restored.xml
<opnsense><note>lab config export</note></opnsense>الاستعادة نجحت بالكامل: الحساب وكلمة مروره الرئيسية، والتحقق الثنائي الذي فعله sami بعد إنشاء حسابه، وعناصر مجموعة IT السبعة، والمرفق بمحتواه. والآن نكسر الاستعادة لنرى ماذا يحدث لمن نسخ قاعدة البيانات فقط.
كسر التجربة
نسخة دون مجلد المرفقات
أوقفنا السيرفر المستعاد، وأبعدنا المجلد attachments كأنه لم يكن في النسخة، ثم شغلنا السيرفر وحاولنا تنزيل المرفق بالجلسة نفسها:
docker stop lab-pw-vw-r
docker run --rm --name lab-pw-tmp --entrypoint sh -v lab-pw-vw-restore:/data vaultwarden/server:1.37.4 \
-c "mv /data/attachments /data/attachments.hidden"
docker start lab-pw-vw-r
bw sync
bw list items --search OPNsense | jq -c '.[0].attachments[] | {fileName, size}'
bw get attachment fw-config-2026-10.xml --itemid $ID --output /tmp/restored2.xml; echo "exit=$?"والمخرج سوف يكون كما يلي:
Syncing complete.
{"fileName":"fw-config-2026-10.xml","size":"113"}
A 404 error occurred while downloading the attachment.exit=1وفي سجل السيرفر:
[2026-10-14 12:30:14.074][response][INFO] (get_attachment) GET /api/ciphers/<cipher_id>/attachment/<attachment_id> => 200 OK
[2026-10-14 12:30:14.099][request][INFO] GET /attachments/4a76e770-5811-4b91-8288-7d17e21d2008/84e65943fc84b17bc253?token=eyJ0eXAiOiJKV1QiLCJhbGci
[2026-10-14 12:30:14.099][response][INFO] (attachments) GET /attachments/<cipher_id>/<file_id> => 404 Not Foundوهذا أخطر ما في هذه الحالة: الخزنة تعمل، والعنصر يظهر، والمرفق في قائمته باسمه وحجمه، لأن معلوماته في قاعدة البيانات، ولا يكتشف أحد أن الملف نفسه مفقود حتى يحتاجه، وغالباً يكون ذلك في يوم تعطل الجدار الناري. لذلك لا يكفي في اختبار الاستعادة أن تفتح الخزنة Vault، وإنما نزل مرفقاً حقيقياً.
نسخة دون rsa_key.pem
أعدنا المرفقات، وحذفنا هذه المرة rsa_key.pem، ثم شغلنا السيرفر وزامنا بالجلسة المفتوحة:
docker stop lab-pw-vw-r
docker run --rm --name lab-pw-tmp --entrypoint sh -v lab-pw-vw-restore:/data vaultwarden/server:1.37.4 \
-c "mv /data/attachments.hidden /data/attachments && rm /data/rsa_key.pem"
docker start lab-pw-vw-r
docker logs lab-pw-vw-r 2>&1 | grep -i "private key" | tail -1
bw syncوالمخرج سوف يكون كما يلي:
[2026-10-14 12:30:32.117][vaultwarden::auth][INFO] Private key 'data/rsa_key.pem' created correctly
Unauthorized response received for request to /accounts/revision-date. Attempting request again.
Syncing failed: {"response":{"error":{"code":401,"reason":"Unauthorized","description":"The request requires user authentication."}},"statusCode":401}وفي سجل السيرفر السبب:
[2026-10-14 12:30:41.698][vaultwarden::auth][ERROR] Error decoding JWT: Error(InvalidSignature)السيرفر لم يجد المفتاح فأنشأ مفتاحاً جديداً بصمت، فأصبح توقيع كل توكن قديم غير صالح، وسقطت كل الجلسات المفتوحة. والبيانات سليمة، فبعد تسجيل دخول جديد عرض sami عناصره السبعة كاملة، ولكن تخيل ذلك في شركة: كل موظف على كل جهاز يجب أن يسجل الدخول من جديد، وكل رابط دعوة أرسلته ولم يفتح بعد يصبح غير صالح لأنه موقع بالمفتاح القديم، كما تذكر صفحة النسخ الاحتياطي في الويكي. لذلك المفتاح جزء من النسخة، حتى لو لم يكن جزءاً من البيانات.
التسجيل المفتوح
شغلنا السيرفر المستعاد مرة أخرى مع SIGNUPS_ALLOWED=true، وهي القيمة الافتراضية إذا لم تكتب السطر في ملف Compose، ثم كررنا محاولة التسجيل من بريد خارجي:
curl -sS --cacert /work/lab-root.crt -X POST https://vw-restore.lab.test:18444/identity/accounts/register/send-verification-email \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","name":"Stranger","receiveMarketingEmails":false}' -w '\nHTTP %{http_code}\n' | cut -c1-200والمخرج سوف يكون كما يلي، وقد قصرنا السطر الأول بالأمر cut لأنه توكن طويل:
eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJuYmYiOjE3OTE5ODEwOTMsImV4cCI6MTc5MTk4Mjg5MywiaXNzIjoiaHR0cHM6Ly92dy1yZXN0b3JlLmxhYi50ZXN0OjE4NDQ0fHJlZ2lzdGVyX3ZlcmlmeSIsInN1YiI6InN0cmFuZ2VyQG91dHNpZGUudGVzdCI
HTTP 200بدلاً من الرفض الذي رأيناه في البداية، أعطى السيرفر توكن تسجيل مباشرة، لأن هذا السيرفر بلا بريد مضبوط فلا يوجد تحقق من البريد، وبه يكمل أي شخص يصل إلى العنوان إنشاء حساب. والحساب وحده لا يرى شيئاً من المؤسسة، ولكنه يعطي الغريب مكاناً على سيرفرك يخزن فيه ما يشاء، ويعطيه واجهات أكثر يختبرها. لذلك اكتب SIGNUPS_ALLOWED=false دائماً، وتأكد منه بالأمر أعلاه بعد كل تحديث للإعدادات.
التحديثات
ثبت الإصدار في ملف Compose كما فعلنا بـ vaultwarden/server:1.37.4، ولا تستخدم latest، والسبب أنك تريد أن تقرأ ملاحظات الإصدار قبل أن يصل إليك، وملاحظات 1.37.4 مثال جيد: سبع ثغرات أمنية، وتغيير في طريقة قراءة عنوان الكلاينت خلف أكثر من Proxy، وحذف نقاط قديمة يعتمد عليها سطر أوامر قديم. وطريقة التحديث التي نوصي بها هي التالية:
- اقرأ صفحة الإصدار الجديد في صفحة الإصدارات، وانتبه لقسم «Upgrade notes» و«Security Fixes».
- شغل سكربت النسخ الاحتياطي، وتأكد أن الأرشيف موجود.
- غير رقم الإصدار في ملف Compose، ثم
docker compose pullوdocker compose up -d. - افتح الخزنة Vault من المتصفح، وزامن من سطر الأوامر، ونزل مرفقاً.
ولم نجرب في هذه التجربة تحديثاً من إصدار إلى آخر، لأننا بدأنا من آخر إصدار، والخطوات أعلاه مبنية على صفحتي الإصدارات والنسخ الاحتياطي.
الدخول الموحد SSO
يدعم Vaultwarden الدخول عبر OpenID Connect منذ الإصدار 1.35.0، فيمكن أن يدخل الموظفون عبر authentik الذي ثبتناه في دليل authentik، وتفعله المتغيرات SSO_ENABLED وSSO_AUTHORITY وSSO_CLIENT_ID وSSO_CLIENT_SECRET بحسب صفحة SSO في الويكي. ولكن انتبه لثلاث نقاط تذكرها الصفحة نفسها:
- كلمة المرور الرئيسية تبقى مطلوبة ولا يتحكم فيها مزود الهوية، فـ SSO هنا يتحكم في من يدخل، وتعطيل الموظف في مزود الهوية يمنعه من الدخول من جديد، ولكنه لا يغني عن سحب وصوله في المؤسسة.
SSO_AUTHORITYيجب أن يطابق الحقلissuerفي وثيقة الاكتشاف حرفاً بحرف.- تفعيل
SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATIONمعSSO_SIGNUPS_MATCH_EMAILقد يسمح بالاستيلاء على حساب، فلا تفعلهما معاً.
ولم نفعل SSO في هذه التجربة، فربطه بـ authentik وما يحدث للجلسات عند سحب الدور موضوع مستقل، وقد شرحنا الفكرة العامة في إدارة الأدوار عبر التطبيقات.
إزالة التجربة
نحذف ما أنشأناه بالاسم فقط، ثم Docker Images التي نزلناها للتجربة إذا لم تكن تستخدمها في شيء آخر:
docker rm -f lab-pw-vw-r lab-pw-cli lab-pw-caddy lab-pw-vw lab-pw-mailpit
docker volume rm lab-pw-vw-data lab-pw-caddy-data lab-pw-vw-restore
docker network rm lab-pw-net
docker rmi vaultwarden/server:1.37.4 node:24.21.0-alpineأخطاء شائعة
- ترك
SIGNUPS_ALLOWEDبقيمته الافتراضية: القيمة الافتراضيةtrue، فكل من يصل إلى العنوان يستطيع أن ينشئ حساباً. - رمز الإدارة نصاً صريحاً: من يقرأ ملف
.envأو مخرجdocker inspectيصبح مدير السيرفر، فاستخدم هاش Argon2، وأغلق/adminمن خارج شبكة الإدارة. - نسخ
db.sqlite3وحده: دون المرفقات تفقد الملفات بصمت، ودونrsa_key.pemتسقط كل الجلسات والدعوات. - نسخ قاعدة البيانات بأمر نسخ عادي وهي تعمل: استخدم
/vaultwarden backup، وعند الاستعادة احذف أيdb.sqlite3-walقديم. - مالك واحد للمؤسسة: الوصول الطارئ Emergency Access لا يغطي عناصر المؤسسة كما رأينا، فأضف مالكاً ثانياً.
- فرض التحقق الثنائي دون إبلاغ: كل عضو بلا تحقق ثنائي يفقد وصوله في اللحظة نفسها.
- الـ Proxy لا يمرر عنوان الكلاينت: فيظهر عنوان الـ Proxy في السجل، ويحظره fail2ban فيقطع الخدمة عن الجميع.
- استخدام
latest: تصلك تغييرات تكسر الإعداد دون أن تقرأ ملاحظاتها.
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- الخزنة في المتصفح لا تعمل عبر HTTP لأن Web Crypto تحتاج سياقاً آمناً، وسطر أوامر Bitwarden يرفض HTTP أيضاً، فضع Vaultwarden خلف Reverse Proxy بشهادة يثق بها الموظفون، عامة أو من جهة إصدار داخلية.
- رمز الإدارة بهاش Argon2، والتسجيل مغلق بـ
SIGNUPS_ALLOWED=false، والدخول بالدعوة فقط، وإنشاء المؤسسات محصور في حساب تقنية المعلومات. - العضو في فريق، والفريق يصل إلى مجموعة، والعضو لا يحصل على مفتاح المؤسسة حتى يؤكده المالك.
- ترحيل ملف Excel يحتاج أكثر من حفظه بصيغة CSV: سطر العناوين ليس دائماً الأول، والصفوف تتكرر، والأرقام تفقد أصفارها، وكل كلمة مرور في الملف تحتاج تغييراً بعد الاستيراد.
- سياسة التحقق الثنائي تسحب وصول من لم يفعله فوراً، والوصول الطارئ يغطي الخزنة الشخصية وليس عناصر المؤسسة.
- النسخة الاحتياطية هي قاعدة البيانات والمرفقات و
rsa_key.pem، واختبارها يعني أن تنزل مرفقاً من السيرفر المستعاد، وليس أن تفتح الخزنة Vault فقط.
دليلإدارة كلمات المرور في المؤسسة: من ملف Excel إلى خزنةلماذا لا يصلح ملف Excel لكلمات مرور الشركة، وكيف تنقل الحسابات المشتركة إلى خزنة بصلاحيات وسجل وخطة طوارئ.
دليلBitwarden أم Vaultwarden أم Passbolt؟ مدير كلمات مرور لفريقكمقارنة بين مديري كلمات المرور للفرق في طريقة المشاركة والترخيص والسعر والمزايا، ودليل يساعدك على اختيار الأنسب لشركتك.
دليلتثبيت authentik للدخول الموحد SSO إلى تطبيقاتككيف تثبت authentik حتى يدخل كل موظف إلى تطبيقات الفريق بحساب واحد، مع فرض المصادقة الثنائية على الجميع.
دليلتثبيت Caddy على خادمك: Reverse Proxy بشهادات HTTPS تلقائيةكيف تثبت Caddy على سيرفرك ليمنح كل موقع شهادة HTTPS تلقائياً، وتربط به تطبيقاتك بملف إعداد من بضعة أسطر.
دليلالنسخ الاحتياطي بقاعدة 3-2-1 واختبار الاستعادةكيف تحدد كم بيانات تقبل أن تخسر، وتبني نسخاً لا يستطيع المخترق حذفها، وتتأكد أنك تستطيع استعادتها فعلاً.
دليلالدخول دون كلمة مرور: شرح Passkeys وWebAuthnلماذا لا تحميك رموز SMS وTOTP من التصيد الحديث، وكيف تحل Passkeys المشكلة بمفتاح خاص لا يغادر جهازك.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ التجربة في 14 أكتوبر 2026 على Docker Engine 29.8.0 مع Vaultwarden 1.37.4 (الخزنة في المتصفح 2026.7.0)، وCaddy 2.11.7، وMailpit 1.31.5، وسطر أوامر Bitwarden 2026.9.1 على Node.js 24.21.0، وPython 3.13.16 مع openpyxl 3.1.5، وChrome 154 للخزنة في المتصفح، وكل الحسابات والعناوين وكلمات المرور مصطنعة للتجربة.