الأدلة التقنية

Radicale: سيرفر تقويم وجهات اتصال خفيف لفريقك

كيف تشغل Radicale خلف Reverse Proxy بكلمات مرور bcrypt وتقويم مشترك للفريق، وتحفظ كل تغيير في git لتسترجعه عند الخطأ.

Radicale: سيرفر تقويم وجهات اتصال خفيف لفريقك

في مقال التقويم وجهات الاتصال على سيرفرك مع CalDAV وCardDAV شرحنا كيف تعمل المزامنة نفسها: الملفات iCalendar وvCard، والبروتوكولات WebDAV وCalDAV وCardDAV، والاكتشاف التلقائي Discovery، وجولة المزامنة بالأمر curl. واستخدمنا هناك Radicale لأنه أصغر سيرفر يمكن أن تشغله في دقيقة، ولكننا شغلناه للتجربة فقط: مستخدم واحد، ودون Reverse Proxy، ودون صلاحيات، ودون نسخ احتياطي. أما في هذا المقال فسوف نجعله سيرفراً حقيقياً لفريق صغير، تدخل إليه أجهزة الموظفين من الإنترنت، ولكل موظف تقويمه، وللفريق تقويم مشترك يكتب فيه شخص واحد ويقرؤه الجميع، وكل تغيير محفوظ في git بحيث تستطيع أن ترجع عن أي حذف بالخطأ.

وسوف نناقش في هذا المقال ما يلي:

  • متى يناسبك Radicale، وما الذي لا يقدمه.
  • التثبيت مع Docker Compose بال Docker Image الرسمية، وملفات الإعداد الثلاثة.
  • المستخدمون وكلمات المرور بصيغة bcrypt.
  • تشغيله خلف Reverse Proxy على المسار /radicale/ مع الاكتشاف التلقائي.
  • تقويم مشترك للفريق بملف الصلاحيات rights، ثم المشاركة الجديدة التي تظهر في أجهزة الزميل تلقائياً.
  • اختبارات CalDAV وCardDAV بالأمر curl، وعميل حقيقي هو vdirsyncer مع khal.
  • سجل كل تغيير في git، واسترجاع موعد حذف بالخطأ، والنسخ الاحتياطي واختبار الاستعادة، والتحديث.

متى يناسبك Radicale؟

Radicale سيرفر CalDAV وCardDAV مكتوب بلغة Python، ورخصته GNU GPL v3، وآخر إصدار له وقت كتابة المقال هو 3.8.3 الصادر في 8 أكتوبر 2026. وأهم ما يميزه أنه لا يستخدم قاعدة بيانات أصلاً، فكل موعد ملف .ics وكل جهة اتصال ملف .vcf في مجلد على القرص، والصلاحيات في ملف نصي، وكلمات المرور في ملف htpasswd، لذلك تستطيع أن تفهم كل ما يحدث فيه بالأوامر ls وcat، وتنسخه احتياطياً بنسخ مجلد واحد.

ولكن هذه البساطة لها حدود يجب أن تعرفها قبل أن تختاره:

  • لا توجد جدولة على السيرفر: Radicale لا يطبق جدولة CalDAV Scheduling في RFC 6638، والدليل في المختبر أن ترويسة DAV التي يرسلها هي 1, 2, 3, calendar-access, addressbook, extended-mkcol دون calendar-auto-schedule. وهذا يعني أن الدعوات وقبولها تعتمد على تطبيق البريد في جهاز كل موظف، كما شرحنا في قسم الدعوات. ومنذ الإصدار 3.5.5 يستطيع Radicale أن يرسل بريد تنبيه للمدعوين عبر الخيار [hook] type = email، ولكنه تنبيه وليس جدولة كاملة.
  • لا توجد لوحة إدارة للمستخدمين: تضيف المستخدم بسطر في ملف، وتعدل الصلاحيات في ملف آخر.
  • Outlook لا يدعم CalDAV وحده، ويحتاج إلى إضافة كما ذكرنا في قسم Outlook.

لذلك فهو مناسب لفريق صغير أو متوسط يستخدم الهواتف وThunderbird وتطبيقات CalDAV المعتادة، ويريد شيئاً لا يحتاج إلى صيانة تقريباً. أما إذا كان فريقك يعيش في Outlook ويحتاج إلى حجز القاعات والدعوات من السيرفر، فالأنسب حل مثل SOGo داخل mailcow.

التثبيت مع Docker Compose

لم تكن هناك Docker Image رسمية ل Radicale لسنوات، أما اليوم فالمشروع ينشر Docker Image رسمية على Docker Hub وGitHub كما في التوثيق، وهي مبنية على Alpine، وفيها مكتبة bcrypt وأداة htpasswd وgit، وتعمل بمستخدم عادي رقمه 1000 وليس root، وحجمها المضغوط نحو 38 MB. وسوف نستخدم الوسم 3.8.3 وليس latest أو stable، لأن هذين الوسمين يتغيران مع كل إصدار، فيتحدث سيرفرك دون أن تقرر أنت ذلك.

والصورة التالية تبين طريق الموعد من جهاز الموظف إلى ملف على القرص، وهو ما سوف نبنيه في هذا المقال:

رسم يوضح الهاتف وThunderbird وvdirsyncer مع khal تتصل عبر HTTPS بال Reverse Proxy على المسار radicale، ثم Radicale على المنفذ 5232 مع ملف المستخدمين بصيغة bcrypt وملف الصلاحيات، ثم مجلد collections الذي فيه ملف لكل موعد، ثم سجل git الذي يحفظ كل تغيير
كل موعد ملف نصي على القرص، وكل تغيير يصبح Commit في git

نبدأ بالمجلد وملفاته، وشكله النهائي كما يلي:

radicale/
├── compose.yaml
├── config/
│   ├── config      # main settings
│   ├── users       # htpasswd file, bcrypt
│   └── rights      # who can read and write what
└── proxy/
    └── default.conf

وملف الإعداد الرئيسي config/config هو التالي:

[server]
hosts = 0.0.0.0:5232

[auth]
type = htpasswd
htpasswd_filename = /etc/radicale/users
htpasswd_encryption = autodetect

[rights]
type = from_file
file = /etc/radicale/rights

[storage]
filesystem_folder = /var/lib/radicale/collections
hook = git add -A && (git diff --cached --quiet || git commit -q -m "Changes by \"%(user)s\"")

في الإعداد أعلاه لاحظ التالي:

  • hosts = 0.0.0.0:5232 يجعل Radicale يستمع داخل ال Container على كل الواجهات، ولكننا لن ننشر المنفذ 5232 إلى الخارج، فلا يصل إليه إلا ال Reverse Proxy عبر شبكة Docker.
  • htpasswd_encryption = autodetect يكتشف صيغة كل كلمة مرور في الملف، وهو الإعداد الافتراضي منذ الإصدار 3.3.0.
  • type = from_file يجعل الصلاحيات من ملفنا بدلاً من الإعداد الافتراضي owner_only، والسبب أننا نريد تقويماً مشتركاً للفريق.
  • السطر hook هو الأمر الذي يقترحه قسم Versioning collections with Git في التوثيق، ويشغله Radicale بعد كل تغيير، فيصبح كل تغيير Commit باسم المستخدم الذي قام به. وأضفنا -q فقط لكي لا يكتب git رسالته في سجل Radicale مع كل تغيير.

وملف compose.yaml هو التالي:

services:
  radicale:
    image: kozea/radicale:3.8.3
    restart: unless-stopped
    volumes:
      - ./config:/etc/radicale:ro
      - data:/var/lib/radicale

  proxy:
    image: nginx:1.29-alpine
    restart: unless-stopped
    depends_on: [radicale]
    ports:
      - "127.0.0.1:28232:80"
    volumes:
      - ./proxy/default.conf:/etc/nginx/conf.d/default.conf:ro

volumes:
  data:

ال Container الخاص ب Nginx هنا يمثل ال Reverse Proxy الموجود عندك، وفي المختبر جعلناه على 127.0.0.1:28232 دون TLS لأن الاختبار كله على الجهاز نفسه. أما على السيرفر الحقيقي فيكون ال Reverse Proxy هو الذي يصدر شهادة TLS، كما في Nginx Proxy Manager أو Caddy، ولا تقم أبداً بتشغيل CalDAV عبر HTTP على الإنترنت، والسبب أن الأجهزة ترسل اسم المستخدم وكلمة المرور مع كل طلب بطريقة Basic Authentication.

جهز git قبل أول تغيير

شغلنا ال Containers ثم أنشأنا أول تقويم قبل أن نجهز مستودع git، لنرى ما يحدث:

docker compose up -d
curl -s -o /dev/null -w 'MKCALENDAR alice/personal %{http_code}\n' -u 'alice:Alice-Lab-2026!' \
  -X MKCALENDAR http://127.0.0.1:28232/radicale/alice/personal/
docker compose logs radicale | grep 'storage hook' | tail -1

والمخرج سوف يكون كما يلي:

MKCALENDAR alice/personal 201
radicale-1  | [2026-10-09 00:03:13 +0000] [1/Thread-1 (process_request_thread)] [ERROR] Execution of storage hook not successful: Command 'git add -A && (git diff --cached --quiet || git commit -q -m "Changes by \"alice\"")' returned non-zero exit status 128.

لاحظ أن التقويم أنشئ بنجاح ورد السيرفر ب 201، بينما فشل الأمر hook لأن المجلد ليس مستودع git بعد، ولا يرى المستخدم أي خطأ، وهذا هو الخطر هنا: قد تعمل شهوراً وأنت تظن أن كل شيء محفوظ في git. لذلك جهز المستودع داخل ال Container بالمستخدم الذي يشغل Radicale، وذلك بالأوامر التي يذكرها التوثيق:

docker compose exec radicale sh -c '
cd /var/lib/radicale/collections
git init -q
git config user.name radicale
git config user.email [email protected]
printf ".Radicale.cache\n.Radicale.lock\n.Radicale.tmp-*\n" > .gitignore
git add -A && git commit -q -m "Initial state" && git log --oneline'

والمخرج سوف يكون كما يلي:

cdbe0c6 Initial state

والملف .gitignore يستثني ذاكرة Radicale المؤقتة وملف القفل والملفات المؤقتة، لأنها تتغير باستمرار ولا قيمة لها في السجل. وبعد ذلك ابحث في السجل عن الكلمة storage hook مرة كل فترة، أو أضفها إلى نظام المراقبة عندك.

المستخدمون وكلمات المرور بصيغة bcrypt

منذ الإصدار 3.5.0 يرفض Radicale كل محاولات الدخول بشكل افتراضي حتى تحدد طريقة المصادقة، أما الإصدارات الأقدم فكانت تقبل أي اسم وأي كلمة مرور، فإذا وجدت سيرفراً قديماً مكشوفاً على الشبكة فهذه أول نقطة تصلحها. والتوثيق يذكر طريقة بسيطة هي كتابة كلمات المرور كنص عادي في الملف مع htpasswd_encryption = plain، وهي طريقة غير مناسبة إطلاقاً، لأن من يقرأ نسخة احتياطية واحدة من الملف يأخذ كلمات مرور كل الفريق. والطريقة الصحيحة هي الأداة htpasswd مع الخيار -B لصيغة bcrypt، وهي موجودة داخل ال Docker Image نفسها، لذلك لا تحتاج إلى تثبيتها على السيرفر:

docker run --rm -it --entrypoint htpasswd -v "$PWD/config:/c" kozea/radicale:3.8.3 -B -C 12 -c /c/users alice
docker run --rm -it --entrypoint htpasswd -v "$PWD/config:/c" kozea/radicale:3.8.3 -B -C 12 /c/users bob

والخيار -c ينشئ الملف من جديد، لذلك استخدمه مع أول مستخدم فقط، و-C 12 يحدد كلفة bcrypt، وكلما زادت صار تخمين كلمة المرور أبطأ على المهاجم وصار الدخول أبطأ قليلاً على السيرفر. وكل سطر في الملف يكون بالشكل التالي:

alice:$2y$12$v1J5PKA4AbO8P.izU4Vrnesq3xhUiwJF9boh15mWlCfnXM4FqzbJG

وبعد التشغيل يكتب Radicale في سجله عدد كلمات المرور بصيغة bcrypt التي وجدها، وفي المختبر أضفنا ثلاثة مستخدمين هم alice وbob وcarol:

radicale=3.8.3
bcrypt entries found: 3
rights type is 'radicale.rights.from_file'

وعند كلمة مرور خاطئة يرد السيرفر ب 401 بعد تأخير، لأن الخيار delay في القسم [auth] قيمته الافتراضية ثانية واحدة، وهذا يبطئ تخمين كلمات المرور. ولا تحتاج إلى إعادة تشغيل Radicale بعد إضافة مستخدم، ففي المختبر أضفنا مستخدماً رابعاً إلى الملف فدخل مباشرة ورد السيرفر على طلبه ب 207.

خلف Reverse Proxy على المسار /radicale/

إذا كان Radicale على نطاق فرعي خاص به مثل dav.example.com فالإعداد بسيط، أما إذا أردته تحت مسار مثل /radicale/ بجانب خدمات أخرى فهناك شرطان يذكرهما قسم Reverse Proxy في التوثيق: أن يحذف ال Proxy المسار من الطلب قبل أن يمرره، وأن يخبر Radicale بالمسار في الترويسة X-Script-Name لكي يكتبه في الروابط التي يرسلها للأجهزة. وهذا هو ملف proxy/default.conf:

server {
    listen 80;
    server_name dav.example.com;
    absolute_redirect off;

    # lets phones and Thunderbird find the server from the domain name alone
    location = /.well-known/caldav  { return 301 /radicale/; }
    location = /.well-known/carddav { return 301 /radicale/; }

    location /radicale/ {                       # the trailing / is important
        proxy_pass        http://radicale:5232/;
        proxy_set_header  X-Script-Name /radicale;
        proxy_set_header  X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header  X-Forwarded-Host $host;
        proxy_set_header  X-Forwarded-Port $server_port;
        proxy_set_header  X-Forwarded-Proto $scheme;
        proxy_set_header  Host $http_host;
        proxy_pass_header Authorization;
    }
}

في الإعداد أعلاه لاحظ التالي:

  • الشرطة المائلة في آخر proxy_pass http://radicale:5232/ هي التي تجعل Nginx يحذف /radicale/ من الطلب، فيصل الطلب /radicale/alice/ إلى Radicale بالشكل /alice/.
  • المساران /.well-known/caldav و/.well-known/carddav هما ما يطلبه الهاتف عندما تكتب له اسم النطاق فقط، كما شرحنا في قسم الاكتشاف التلقائي، ودونهما يجب على كل موظف أن يكتب المسار كاملاً.
  • السطر absolute_redirect off أضفناه بعد خطأ رأيناه في المختبر، فدونه يكتب Nginx في التحويل عنواناً كاملاً بالمنفذ الذي يستمع عليه داخل ال Container، فكانت النتيجة http://127.0.0.1/radicale/ دون المنفذ 28232، وهو عنوان لا يعمل. ومعه يرسل المسار فقط، فيكمله الجهاز بالعنوان الذي يعرفه.

الآن نختبر كلمة مرور خاطئة، ثم الاكتشاف، ثم نسأل السيرفر من هو المستخدم الحالي وأين مجلده Principal:

B=http://127.0.0.1:28232
curl -s -o /dev/null -w 'wrong password: %{http_code}\n' -u 'alice:wrong' -X PROPFIND -H 'Depth: 0' $B/radicale/
curl -sI $B/.well-known/caldav | grep -i '^HTTP\|^location'
curl -s -u 'alice:Alice-Lab-2026!' -X PROPFIND -H 'Depth: 0' -H 'Content-Type: application/xml' \
  --data '<propfind xmlns="DAV:"><prop><current-user-principal/></prop></propfind>' $B/radicale/

والمخرج سوف يكون كما يلي، بعد ترتيب ال XML في أسطر:

wrong password: 401
HTTP/1.1 301 Moved Permanently
Location: /radicale/
<?xml version='1.0' encoding='utf-8'?>
<multistatus xmlns="DAV:">
<response>
<href>/radicale/</href>
<propstat>
<prop>
<current-user-principal>
<href>/radicale/alice/</href>
</current-user-principal>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>
</multistatus>

لاحظ أن Radicale كتب المسار /radicale/alice/ مع البادئة، وهذا بفضل الترويسة X-Script-Name، ولو حذفتها لرد ب /alice/ فيطلب الجهاز مساراً لا يعرفه ال Proxy، وتفشل المزامنة بعد أن نجح تسجيل الدخول، وهو خطأ مربك لأن كلمة المرور صحيحة. وإذا وضعت Radicale خلف Cloudflare فراجع إعداد Cloudflare مع سيرفرك، ولاحظ أن ال Proxy فيه يحمل الطلبات PROPFIND وREPORT لأنها HTTP عادي.

تقويم مشترك للفريق بملف الصلاحيات

الإعداد الافتراضي owner_only يعطي كل مستخدم مجلده /USERNAME/ فقط، وهذا مناسب لتقويمات الأشخاص. ولكن الفريق يحتاج غالباً إلى تقويم مشترك، مثل اجتماعات الفريق أو الإجازات، يكتب فيه شخص واحد أو أكثر ويقرؤه الجميع. وهنا يأتي ملف rights، وقواعده كما في قسم Authorization and Rights: كل قسم فيه تعبير نمطي Regular Expression لاسم المستخدم user وآخر لمسار المجلد collection، وأول قسم يطابق هو الذي يطبق، وإذا لم يطابق أي قسم فالوصول مرفوض. والصلاحيات حروف: R لقراءة المجلدات العادية، وr لقراءة التقويمات ودفاتر العناوين، وW وw للكتابة بالترتيب نفسه. وهذا هو الملف الذي استخدمناه:

# Everyone who signed in can see the top level
[root]
user: .+
collection:
permissions: R

# Each user owns /USERNAME/ and everything directly inside it
[principal]
user: .+
collection: {user}
permissions: RW

[own-calendars]
user: .+
collection: {user}/[^/]+
permissions: rw

# /team/ is a shared folder: alice manages it
[team-folder-alice]
user: alice
collection: team
permissions: RW

[team-folder]
user: .+
collection: team
permissions: R

# alice writes the team calendars, everyone else reads them
[team-calendars-alice]
user: alice
collection: team/[^/]+
permissions: rw

[team-calendars]
user: .+
collection: team/[^/]+
permissions: r

في الإعداد أعلاه لاحظ التالي:

  • التعبير .+ يطابق أي مستخدم سجل دخوله، أما .* فيطابق حتى المستخدم المجهول الذي اسمه فارغ، لذلك لا تستخدم .* إلا إذا أردت أن يقرأ الجميع دون كلمة مرور.
  • {user} يستبدل باسم المستخدم الحالي، فتصبح القاعدة [own-calendars] لكل مستخدم على مجلده هو فقط.
  • ترتيب الأقسام مهم، فقسم alice يأتي قبل القسم العام الذي يعطي القراءة فقط، ولو عكست الترتيب لطابق القسم العام أولاً ولما استطاعت alice أن تكتب.

وبعد ذلك تنشئ alice المجلد والتقويم المشترك، وتضيف موعداً متكرراً، ونجرب ما يستطيع bob وcarol فعله:

A='alice:Alice-Lab-2026!'; Bo='bob:Bob-Lab-2026!'; C='carol:Carol-Lab-2026!'
curl -s -o /dev/null -w 'alice MKCOL team: %{http_code}\n' -u "$A" -X MKCOL $B/radicale/team/
curl -s -o /dev/null -w 'alice MKCALENDAR team/calendar: %{http_code}\n' -u "$A" -X MKCALENDAR $B/radicale/team/calendar/
curl -s -o /dev/null -w 'bob MKCOL sales: %{http_code}\n' -u "$Bo" -X MKCOL $B/radicale/sales/
curl -s -o /dev/null -w 'alice PUT team event: %{http_code}\n' -u "$A" -T standup.ics -H 'Content-Type: text/calendar' $B/radicale/team/calendar/standup.ics
curl -s -o /dev/null -w 'bob GET team event: %{http_code}\n' -u "$Bo" $B/radicale/team/calendar/standup.ics
curl -s -o /dev/null -w 'bob PUT team event: %{http_code}\n' -u "$Bo" -T standup.ics -H 'Content-Type: text/calendar' $B/radicale/team/calendar/bob-edit.ics
curl -s -o /dev/null -w 'bob DELETE team event: %{http_code}\n' -u "$Bo" -X DELETE $B/radicale/team/calendar/standup.ics
curl -s -o /dev/null -w 'carol PROPFIND alice/personal: %{http_code}\n' -u "$C" -X PROPFIND -H 'Depth: 1' $B/radicale/alice/personal/

والملف standup.ics موعد يتكرر كل اثنين وأربعاء الساعة 9 صباحاً بتوقيت الرياض:

BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//ArabRoot//lab//EN
BEGIN:VEVENT
UID:[email protected]
DTSTAMP:20261009T080000Z
DTSTART;TZID=Asia/Riyadh:20261012T090000
DTEND;TZID=Asia/Riyadh:20261012T091500
RRULE:FREQ=WEEKLY;BYDAY=MO,WE
SUMMARY:Team standup
END:VEVENT
END:VCALENDAR

والمخرج سوف يكون كما يلي:

alice MKCOL team: 201
alice MKCALENDAR team/calendar: 201
bob MKCOL sales: 403
alice PUT team event: 201
bob GET team event: 200
bob PUT team event: 403
bob DELETE team event: 403
carol PROPFIND alice/personal: 403

كل نتيجة هنا مطابقة لما كتبناه في الملف: alice تدير /team/، وbob لا يستطيع أن ينشئ مجلداً عاماً جديداً، ويقرأ موعد الفريق ولا يعدله ولا يحذفه، وcarol لا ترى تقويم alice الشخصي أصلاً.

ولكن هناك مشكلة يذكرها التوثيق صراحة، وهي أن الأجهزة تبحث عن التقويمات داخل مجلد المستخدم فقط، والطلب التالي يبين ما يراه bob عندما يسأل عن مجلده:

curl -s -u "$Bo" -X PROPFIND -H 'Depth: 1' -H 'Content-Type: application/xml' \
  --data '<propfind xmlns="DAV:"><prop><resourcetype/></prop></propfind>' $B/radicale/bob/ | grep -o '<href>[^<]*</href>'

والمخرج سوف يكون كما يلي:

<href>/radicale/bob/</href>
<href>/radicale/bob/personal/</href>

تقويم الفريق غير موجود في القائمة، لأنه في /team/ وليس في /bob/، فلن يظهر في هاتف bob تلقائياً، وعليه أن يضيفه برابطه الكامل https://dav.example.com/radicale/team/calendar/، وهذا ممكن في DAVx⁵ وThunderbird وvdirsyncer. وإذا كان هذا مقبولاً في فريقك فملف الصلاحيات وحده كاف، وإلا فالحل في القسم التالي.

مشاركة تظهر في أجهزة الزميل تلقائياً

منذ الإصدار 3.7.0 أضاف Radicale نظام مشاركة Collection Sharing، وشرحه في ملف SHARING.md. والنوع الذي يهمنا هو المشاركة بالربط Map، حيث يربط صاحب التقويم مساراً داخل مجلد زميله بتقويمه، فيظهر التقويم في مجلد الزميل كأنه له، وتطبق عليه الصلاحية التي يحددها صاحب التقويم. وهذه الميزة معطلة بشكل افتراضي، ونفعلها بإضافة القسم التالي إلى config/config ثم إعادة تشغيل Radicale:

[sharing]
type = csv
collection_by_map = true
permit_create_map = true

ثم تنشئ alice المشاركة عبر واجهة الإدارة API، ويقبلها bob بخطوتين، لأن التوثيق يشترط أن يفعل المستخدم المشاركة بنفسه حتى لا يظهر في أجهزته شيء لم يطلبه:

# alice shares /team/calendar/ into bob's home, read only
curl -s -u "$A" -d "PathOrToken=/bob/team-calendar/" -d "PathMapped=/team/calendar/" -d "User=bob" \
  -d "Permissions=r" -d "Enabled=True" -d "Hidden=False" $B/radicale/.sharing/v1/map/create

# bob accepts it
curl -s -u "$Bo" -d "PathOrToken=/bob/team-calendar/" $B/radicale/.sharing/v1/map/enable
curl -s -u "$Bo" -d "PathOrToken=/bob/team-calendar/" $B/radicale/.sharing/v1/map/unhide

كل طلب منها رد ب Status='success'، وقبل أن يقبل bob بقيت قائمة مجلده كما هي، وبعد القبول أصبحت كما يلي:

<href>/radicale/bob/</href>
<href>/radicale/bob/personal/</href>
<href>/radicale/bob/team-calendar/</href>
bob GET via share: 200
bob PUT via share: 403

ظهر التقويم في مجلد bob فتكتشفه أجهزته تلقائياً، والكتابة فيه مرفوضة لأن الصلاحية r. والمشاركات محفوظة في الملف collections/collection-db/sharing.csv، أي داخل المجلد نفسه الذي ننسخه احتياطياً، وفي المختبر حفظ ال hook تغييراته في git باسم Anonymous لأن طلبات API المشاركة لا تمرر اسم المستخدم إليه. ولاحظ أن هذه الميزة جديدة نسبياً وتغيرت في الإصدار 3.8.0، لذلك اقرأ سجل التغييرات قبل أي تحديث إذا كنت تعتمد عليها.

اختبارات CalDAV وCardDAV بالأمر curl

شرحنا الطلبات الأساسية خطوة بخطوة في مختبر المقال السابق، ونضيف هنا اختبارين يهمان الفريق. الأول هو طلب REPORT من نوع calendar-query يطلب من السيرفر مواعيد أسبوع واحد، ومع الخيار expand يحول السيرفر الموعد المتكرر إلى مواعيد منفصلة:

curl -s -u "$Bo" -X REPORT -H 'Depth: 1' -H 'Content-Type: application/xml' --data '<?xml version="1.0"?>
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
  <D:prop><C:calendar-data><C:expand start="20261012T000000Z" end="20261019T000000Z"/></C:calendar-data></D:prop>
  <C:filter><C:comp-filter name="VCALENDAR"><C:comp-filter name="VEVENT">
    <C:time-range start="20261012T000000Z" end="20261019T000000Z"/>
  </C:comp-filter></C:comp-filter></C:filter>
</C:calendar-query>' $B/radicale/team/calendar/ | grep -E 'href|DTSTART|SUMMARY|status'

والمخرج سوف يكون كما يلي، بعد ترتيب ال XML في أسطر:

<multistatus xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<href>/radicale/team/calendar/standup.ics</href>
DTSTART:20261012T060000Z
SUMMARY:Team standup
DTSTART:20261014T060000Z
SUMMARY:Team standup
<status>HTTP/1.1 200 OK</status>
</multistatus>

ظهر الاجتماع مرتين في الأسبوع، يوم الاثنين 12 والأربعاء 14، والوقت 060000Z بتوقيت UTC، وهو 9 صباحاً بتوقيت الرياض لأن الفرق ثلاث ساعات، أي أن السيرفر فهم المنطقة الزمنية Asia/Riyadh رغم أن الملف لا يحمل تعريفاً لها. والاختبار الثاني هو دفتر عناوين فيه جهة اتصال باسم عربي، ثم طلب REPORT من نوع addressbook-query يبحث عن جزء من الاسم:

<?xml version="1.0" encoding="utf-8"?>
<CR:addressbook-query xmlns:D="DAV:" xmlns:CR="urn:ietf:params:xml:ns:carddav">
  <D:prop><CR:address-data/></D:prop>
  <CR:filter>
    <CR:prop-filter name="FN">
      <CR:text-match collation="i;unicode-casemap" match-type="contains">سارة</CR:text-match>
    </CR:prop-filter>
  </CR:filter>
</CR:addressbook-query>

وبعد إنشاء دفتر العناوين /alice/contacts/ ورفع جهة الاتصال، نرسل الطلب بالأمر curl --data-binary @query-sara.xml، والمخرج سوف يكون كما يلي:

alice MKCOL contacts: 201
alice PUT sara.vcf: 201
<href>/radicale/alice/contacts/sara.vcf</href>
FN:سارة الأحمد
TEL;TYPE=CELL:+966500000001

البحث بالعربية يعمل، والسبب أن الملفات بترميز UTF-8 والمقارنة i;unicode-casemap تتعامل مع Unicode. واستخدمنا --data-binary مع ملف بدلاً من كتابة النص في سطر الأوامر، لأن بعض الطرفيات على Windows تغير ترميز الحروف العربية قبل أن تصل إلى curl.

عميل حقيقي: vdirsyncer وkhal

الأمر curl يختبر السيرفر، ولكن ما يهمك فعلاً هو ما يراه الموظف في تطبيقه. ولكي يكون الاختبار قابلاً للتكرار في سطر الأوامر استخدمنا vdirsyncer 0.21.0 وهو أداة مزامنة رخصتها BSD-3-Clause، تنسخ التقويمات إلى مجلد على جهازك وتزامنها في الاتجاهين، وkhal 0.14.1 وهو تقويم في الطرفية رخصته MIT يقرأ المجلد نفسه. وشغلناهما في Container مبني من python:3.13-slim-trixie بالأمر pip install vdirsyncer==0.21.0 khal==0.14.1، ومتصل بشبكة Docker نفسها. وهذا إعداد vdirsyncer للمستخدم bob، وكلمة المرور فيه تقرأ من ملف ولا تكتب في الإعداد نفسه:

[general]
status_path = "~/.vdirsyncer/status/"

# bob's own calendars, two-way
[pair bob_calendars]
a = "bob_local"
b = "bob_remote"
collections = ["from a", "from b"]
metadata = ["displayname"]

[storage bob_local]
type = "filesystem"
path = "~/.calendars/"
fileext = ".ics"

[storage bob_remote]
type = "caldav"
url = "http://proxy/radicale/"
username = "bob"
password.fetch = ["command", "cat", "/root/.bobpass"]

# the shared team calendar, read only, by its direct URL
[pair team]
a = "team_local"
b = "team_remote"
collections = null
conflict_resolution = "b wins"

[storage team_local]
type = "filesystem"
path = "~/.calendars/team/"
fileext = ".ics"

[storage team_remote]
type = "caldav"
url = "http://proxy/radicale/team/calendar/"
read_only = true
username = "bob"
password.fetch = ["command", "cat", "/root/.bobpass"]

في الإعداد أعلاه لاحظ التالي:

  • الزوج bob_calendars يكتشف التقويمات داخل مجلد bob، أما تقويم الفريق فله زوج مستقل برابطه المباشر، لأنه لا يظهر في مجلد bob كما رأينا، وcollections = null يعني أن الرابط هو التقويم نفسه وليس مجلداً فيه تقويمات.
  • read_only = true يجعل vdirsyncer يتجاهل أي تغيير محلي على تقويم الفريق بحسب توثيق الإعداد، فلا يحاول أن يرفعه ويحصل على 403.

وإعداد khal يعرف التقويمين ويجعل تقويم الفريق للقراءة فقط:

[calendars]
[[personal]]
path = ~/.calendars/personal/
[[team]]
path = ~/.calendars/team/
readonly = True

[locale]
local_timezone = Asia/Riyadh
default_timezone = Asia/Riyadh
timeformat = %H:%M
dateformat = %Y-%m-%d
longdateformat = %Y-%m-%d
datetimeformat = %Y-%m-%d %H:%M
longdatetimeformat = %Y-%m-%d %H:%M

[default]
default_calendar = personal

الآن نكتشف التقويمات ونزامنها ونعرض أسبوعاً واحداً:

vdirsyncer discover
vdirsyncer sync
khal list 2026-10-12 7d

والمخرج سوف يكون كما يلي، بعد أن أجبنا بنعم على سؤال إنشاء المجلد المحلي:

Discovering collections for pair bob_calendars
bob_local:
bob_remote:
  - "personal" ("bob/personal")
warning: No collection "personal" found for storage bob_local.
Should vdirsyncer attempt to create it? [y/N]: Saved for bob_calendars: collections = ["personal"]
Discovering collections for pair team
team_local:
team_remote:
  - "calendar" ("team/calendar")
warning: Directory /root/.calendars/team does not exist.
warning: No collection null found for storage team_local.
Saved for team: collections = null
Syncing bob_calendars/personal
Syncing team
Copying (uploading) item [email protected] to team_local
Monday, 2026-10-12
09:00-09:15 Team standup ⟳
Wednesday, 2026-10-14
09:00-09:15 Team standup ⟳

ظهر الاجتماع بتوقيت الرياض، والرمز ⟳ يعني أنه موعد متكرر. والآن يضيف bob موعداً في تقويمه الشخصي ويزامنه، ثم يحاول أن يكتب في تقويم الفريق:

khal new -a personal 2026-10-13 14:00 15:00 "Dentist"
vdirsyncer sync
khal list 2026-10-13 1d
khal new -a team 2026-10-15 10:00 11:00 "Bob tries the team calendar"

والمخرج سوف يكون كما يلي:

Syncing bob_calendars/personal
Syncing team
Copying (uploading) item QNB6H4VZ128XH60ETPSPSSJ66XVGMSXDH70N to bob_remote/personal
Tuesday, 2026-10-13
14:00-15:00 Dentist
critical: ERROR: Cannot modify calendar `team` as it is read-only

رفع vdirsyncer الموعد الجديد إلى السيرفر، ورفض khal الكتابة في تقويم الفريق قبل أن يصل الطلب إلى السيرفر أصلاً. وفي الهاتف تحصل على النتيجة نفسها مع DAVx⁵ على Android، أو بإضافة حساب CalDAV في إعدادات iPhone، كما شرحنا في قسم ربط الأجهزة.

سجل كل تغيير في git واسترجاع ما حذف بالخطأ

بعد كل ما سبق أصبح سجل git كما يلي، وكل سطر فيه تغيير واحد باسم من قام به:

docker compose exec radicale sh -c 'cd /var/lib/radicale/collections && git log --format="%h %s"'
d78bd74 Changes by "bob"
05a7ce7 Changes by "alice"
b480019 Changes by "alice"
99c4d04 Changes by "alice"
40e4b56 Changes by "bob"
1094641 Changes by "alice"
cdbe0c6 Initial state

ولنفرض أن alice حذفت اجتماع الفريق بالخطأ من هاتفها، فالحذف يصل إلى السيرفر ثم إلى أجهزة الجميع في المزامنة التالية. وفي المختبر حذفناه بالطلب DELETE ثم زامن bob:

alice DELETE standup: 200
Syncing bob_calendars/personal
Syncing team
error: team: Storage "team_remote" was completely emptied. If you want to delete ALL entries on BOTH sides,then use `vdirsyncer sync --force-delete team`. Otherwise delete the files for team in your status directory.

لاحظ أن vdirsyncer رفض أن يحذف النسخة المحلية لأن التقويم على السيرفر أصبح فارغاً بالكامل، وهي حماية مفيدة، ولكن الهواتف وThunderbird لا تتصرف دائماً بهذا الحذر. أما الاسترجاع على السيرفر فهو ثلاثة أوامر git: نبحث عن آخر Commit حذف الملف، ثم نسترجع الملف من الحالة التي قبله، ثم نحفظ الاسترجاع:

docker compose exec radicale sh -c 'cd /var/lib/radicale/collections
F=collection-root/team/calendar/standup.ics
C=$(git log -1 --format=%h --diff-filter=D -- $F)
echo "deleted in $C"
git checkout "$C^" -- $F
git commit -q -m "Restore standup.ics"
git log -2 --format="%h %s"'

والمخرج سوف يكون كما يلي:

deleted in d764471
de4edba Restore standup.ics
d764471 Changes by "alice"

وفي المزامنة التالية عند bob عاد الاجتماع إلى قائمته يومي الاثنين والأربعاء. ويقول قسم Storage في التوثيق إن تعديل الملفات يدوياً أو بالسكربتات آمن، وإن مجلد .Radicale.cache يعاد بناؤه تلقائياً.

النسخ الاحتياطي واختبار الاستعادة

وقد يتساءل البعض: إذا كان كل شيء في git، فلماذا نحتاج إلى نسخة احتياطية؟ والإجابة أن مستودع git موجود على القرص نفسه، فإذا تلف القرص أو حذف ال Volume ذهب السجل مع البيانات، لذلك git يحميك من الخطأ البشري فقط، ولا يحميك من فقد السيرفر. والنسخة الاحتياطية هنا بسيطة جداً، لأن كل شيء في مجلد واحد هو collections، وفيه المواعيد وجهات الاتصال والمشاركات ومستودع git نفسه:

docker run --rm --entrypoint sh -v radicale_data:/var/lib/radicale:ro -v "$PWD/backup:/backup" kozea/radicale:3.8.3 \
  -c 'tar czf /backup/radicale-2026-10-09.tar.gz -C /var/lib/radicale collections && ls -l /backup | tail -1'
-rw-r--r--    1 radicale radicale     19135 Oct  9 00:05 radicale-2026-10-09.tar.gz

واسم ال Volume هنا radicale_data لأن Docker Compose يضيف اسم المجلد قبل اسم ال Volume، فتأكد من اسمه عندك بالأمر docker volume ls. وبعد ذلك انقل الملف إلى مكان آخر كما في قاعدة 3-2-1، أو اجعل أداة مثل restic تنسخ ال Volume كما في مقارنة restic وborg وkopia.

والنسخة التي لم تجرب استعادتها لا يمكن أن تعتمد عليها، لذلك استعدناها في Volume جديد وشغلنا عليه Radicale ثانياً. وفي المحاولة الأولى ظهر خطأ يستحق الذكر، فقد ركبنا ال Volume الجديد على المسار /data فرفض tar الكتابة:

tar: can't make dir collections: Permission denied

والسبب أن ال Container يعمل بالمستخدم radicale ورقمه 1000، وال Volume الجديد على مسار غير موجود في ال Docker Image يملكه root. أما عندما نركبه على /var/lib/radicale فينسخ Docker ملكية المجلد من ال Docker Image فيصبح للمستخدم radicale، وتنجح الاستعادة:

docker volume create radicale-restore-test
docker run --rm --entrypoint sh -v radicale-restore-test:/var/lib/radicale -v "$PWD/backup:/backup:ro" kozea/radicale:3.8.3 \
  -c 'tar xzf /backup/radicale-2026-10-09.tar.gz -C /var/lib/radicale && git -C /var/lib/radicale/collections log -1 --format="%h %s"'
docker run -d --name radicale-restore-test -v "$PWD/config:/etc/radicale:ro" -v radicale-restore-test:/var/lib/radicale kozea/radicale:3.8.3
docker exec radicale-restore-test sh -c "curl -s -u 'bob:Bob-Lab-2026!' http://localhost:5232/team/calendar/standup.ics | grep SUMMARY"

والمخرج سوف يكون كما يلي:

de4edba Restore standup.ics
SUMMARY:Team standup

السجل والموعد عادا كما كانا. وتذكر أن تنسخ مجلد config أيضاً، ففيه كلمات المرور والصلاحيات، وهو صغير ولكن دونه يجب أن تعيد إنشاء كل المستخدمين.

الواجهة المدمجة

في Radicale واجهة ويب بسيطة على المسار /radicale/.web/، يدخل إليها المستخدم بحسابه فيرى تقويماته ودفاتر عناوينه وروابطها، ويستطيع أن ينشئ تقويماً جديداً أو يرفع ملف .ics أو يشارك تقويماً. والصورة التالية من المختبر بعد دخول alice:

واجهة Radicale 3.8.3 بعد دخول alice، وفيها التقويم الشخصي alice personal دون مواعيد مع رابط free/busy، ودفتر العناوين Contacts وفيه جهة اتصال واحدة، وأزرار لإنشاء مجموعة جديدة ورفع ملف والمشاركة
واجهة Radicale تعرض مجموعات المستخدم وروابطها، وليست تطبيق تقويم

ولاحظ أنها واجهة لإدارة المجموعات وليست تطبيق تقويم، فلا ترى فيها المواعيد نفسها، وإنما يرى الموظف مواعيده في تطبيق التقويم على جهازه.

التحديث إلى إصدار أحدث

التحديث في Radicale بسيط لأنه لا توجد قاعدة بيانات تحتاج إلى ترحيل Migration، ولكن اتبع الخطوات التالية بالترتيب:

  1. اقرأ سجل التغييرات من إصدارك إلى الإصدار الجديد، وانتبه لأي تغيير في القيم الافتراضية، مثل تغيير المصادقة الافتراضية إلى denyall في الإصدار 3.5.0.
  2. خذ نسخة احتياطية من collections وconfig كما في القسم السابق.
  3. غير الوسم في compose.yaml إلى الإصدار الجديد، ثم شغل docker compose pull وdocker compose up -d.
  4. راجع السجل بالأمر docker compose logs radicale، وتأكد أن السطر Radicale server ready ظهر، وأنه لا يوجد storage hook فاشل، ثم زامن من جهاز واحد قبل أن تخبر الفريق.

أدلة تكمل هذا المقال

هذا المقال جزء من مرحلة التقويم وجهات الاتصال في خريطة التعلم، وفيها مقالات قادمة عن Baïkal، والتقويم في Nextcloud، وSOGo في mailcow، ومزامنة Outlook وAndroid وiPhone، ثم مقارنة بينها. وهذه الأدلة التي يعتمد عليها هذا المقال:

دليلالتقويم وجهات الاتصال على سيرفرك مع CalDAV وCardDAVكيف تبقى مواعيدك وجهات اتصالك متطابقة بين الهاتف والحاسوب على سيرفرك، وما الذي يعمل مع Outlook وما الذي لا يعمل.دليلNginx Proxy Manager: نطاقات وشهادات TLS لكل خدماتككيف تربط كل خدمة على سيرفرك بنطاقها وتمنحها شهادة HTTPS مجانية من واجهة بسيطة، دون أن تكتب إعدادات Nginx بنفسك.دليلتثبيت Caddy على خادمك: Reverse Proxy بشهادات HTTPS تلقائيةكيف تثبت Caddy على سيرفرك ليمنح كل موقع شهادة HTTPS تلقائياً، وتربط به تطبيقاتك بملف إعداد من بضعة أسطر.دليلالنسخ الاحتياطي بقاعدة 3-2-1 واختبار الاستعادةكيف تحدد كم بيانات تقبل أن تخسر، وتبني نسخاً لا يستطيع المخترق حذفها، وتتأكد أنك تستطيع استعادتها فعلاً.

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • Radicale يخزن كل موعد وجهة اتصال في ملف نصي دون قاعدة بيانات، وهذا يجعله مناسباً لفريق صغير أو متوسط، ولكنه لا يقدم جدولة الدعوات على السيرفر ولا لوحة لإدارة المستخدمين.
  • استخدم ال Docker Image الرسمية بوسم ثابت مثل 3.8.3، وكلمات مرور bcrypt بالأمر htpasswd -B، ولا تنشر المنفذ 5232 إلى الخارج، واجعل ال Reverse Proxy يقدم TLS.
  • تحت مسار مثل /radicale/ يحتاج Radicale إلى الترويسة X-Script-Name، والمساران /.well-known/caldav و/.well-known/carddav يجعلان الأجهزة تجده من اسم النطاق.
  • ملف rights يطبق أول قسم يطابق، وبه تبني تقويماً مشتركاً يكتب فيه شخص ويقرؤه الجميع، ولكنه لا يظهر في أجهزتهم إلا برابطه أو بالمشاركة Map الجديدة منذ الإصدار 3.7.0.
  • جهز مستودع git قبل أول تغيير، لأن فشل ال hook لا يظهر للمستخدم، وبه تسترجع أي موعد حذف بالخطأ بثلاثة أوامر.
  • git لا يغني عن النسخة الاحتياطية، وانسخ collections وconfig، وجرب الاستعادة على Volume جديد بالمستخدم الصحيح.

سجل التحديثات

  • أكتوبر 2026: كتابة الدليل واختباره على Radicale 3.8.3 بال Docker Image kozea/radicale:3.8.3 (Python 3.14.8، bcrypt 5.0.0)، خلف nginx:1.29-alpine، مع vdirsyncer 0.21.0 وkhal 0.14.1 على python:3.13-slim-trixie، على Docker Engine 29.8.0.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.