Beberapa waktu lalu saya sudah pernah nulis soal cara implementasi CBPolicyD di Carbonio, tapi itu versi single server. Kali ini saya mau cerita kasus yang beda, setup multi server dengan dua MTA sekaligus, dan ternyata ada beberapa hal yang nggak bisa langsung disamakan begitu saja dari versi single server.
Ceritanya begini. Setup Carbonio di kantor itu pakai dua MTA sekaligus buat distribusi beban dan antisipasi kalau salah satu mati. Masalahnya, dua MTA artinya kalau mau pasang rate limit email per user, itu harus beres di kedua server, dan keduanya harus sinkron satu sama lain. Kalau nggak, orang bisa kirim 300 email lewat MTA satu, lalu pindah ke MTA dua dan kirim 300 lagi, limitnya jadi nggak ada gunanya.
Tulisan ini saya buat buat dokumentasi pribadi sekaligus semoga membantu kalian yang kebetulan pegang Carbonio multi server dan butuh rate limit email pakai CBPolicyD alias Cluebringer. Semua nama host dan alamat IP di bawah ini saya ganti jadi placeholder generik, sesuaikan sendiri dengan environment kalian.
Topologi yang Saya Pakai
Supaya langkah langkahnya kebayang, ini gambaran topologinya. Satu server jadi config atau LDAP master, sekaligus nyimpen PostgreSQL buat database policyd, saya sebut ldap.example.com dengan IP x.x.x.1. Dua server lain jadi MTA, saya sebut mta1.example.com dengan IP x.x.x.2 dan mta2.example.com dengan IP x.x.x.3, masing masing jalanin Postfix dan CBPolicyD sebagai client yang connect ke PostgreSQL pusat di ldap.example.com. Domain mail saya sebut domain.tld.
Bedanya dari Setup Single Server
Kalau di tulisan sebelumnya saya pakai database SQLite bawaan karena cuma ada satu server yang jalanin CBPolicyD, di setup multi server ini SQLite nggak bisa dipakai lagi. Alasannya sederhana, SQLite itu file lokal, jadi kalau dipasang di dua MTA terpisah, masing masing MTA punya catatan sendiri yang nggak nyambung satu sama lain. Makanya di setup ini saya pindah ke PostgreSQL yang dipasang terpisah dan diakses bersama oleh kedua MTA.
Langkah 1, Install Dependency di Setiap MTA
Jalankan di mta1.example.com dan mta2.example.com.
apt install libdbd-pg-perl -y
# untuk RHEL
dnf install perl-DBD-Pg -y
Langkah 2, Setup PostgreSQL di Server LDAP atau Config
Pastikan PostgreSQL bisa diakses dari network, bukan cuma localhost. Cek postgresql.conf, pastikan baris ini aktif.
listen_addresses = '*'
Lalu cek pg_hba.conf, tambahkan baris yang membolehkan koneksi dari network MTA kalian, misalnya.
host all all x.x.x.0/24 md5
Restart PostgreSQL setelah ubah konfigurasi.
systemctl restart postgresql
Buat role dan database khusus buat CBPolicyD.
su - postgres
psql
CREATE ROLE policyd WITH LOGIN ENCRYPTED PASSWORD 'passwordkalian';
CREATE DATABASE policyd OWNER policyd;
Import schema Cluebringer, file SQL nya bisa dicari di dokumentasi resmi Carbonio soal CBPolicyD.
PGPASSWORD='passwordkalian' psql -U policyd -h 127.0.0.1 -f pssql_policyd.sql
Langkah 3, Arahkan Konfigurasi CBPolicyD ke PostgreSQL
Di setiap MTA, edit /opt/zextras/conf/cbpolicyd.conf.in, backup dulu sebelum diubah. Ganti bagian [database] jadi seperti ini.
[database]
host=x.x.x.1
dsn=DBI:Pg:database=policyd;port=5432;host=x.x.x.1
Username=policyd
Password=passwordkalian
File .conf.in ini adalah template. Carbonio otomatis regenerate file cbpolicyd.conf tanpa akhiran .in begitu service MTA nya di restart, jadi jangan edit file cbpolicyd.conf langsung, nanti tertimpa.
Langkah 4, Enable Modul CBPolicyD Lewat Provisioning
Jalankan dari server LDAP atau config, ldap.example.com.
su - zextras
carbonio prov mcf zimbraCBPolicydAccessControlEnabled TRUE
carbonio prov mcf zimbraCBPolicydAccountingEnabled TRUE
carbonio prov mcf zimbraCBPolicydQuotasEnabled TRUE
carbonio prov mcf zimbraCBPolicydBypassMode pass
Satu catatan penting di sini. Command carbonio prov -l mcf ... yang pakai flag -l itu kadang gagal diam diam di environment saya. Exit code nol, kelihatan sukses, tapi value di LDAP nggak berubah. Makanya saya pakai command tanpa flag -l, jadi lewat SOAP admin service. Setelah itu selalu saya cek ulang.
carbonio prov gacf | grep -i cbpolicyd
Langkah 5, Enable Service CBPolicyD di Setiap MTA
carbonio prov ms $(hostname -f) +zimbraServiceInstalled cbpolicyd
carbonio prov ms $(hostname -f) +zimbraServiceEnabled cbpolicyd
Langkah 6, Start dan Restart Service
Versi Carbonio yang saya pakai sudah nggak pakai zmconfigdctl atau zmmtactl lagi, semua dikontrol lewat systemd.
# di server config, ldap.example.com
systemctl restart carbonio-configd.service
# di tiap MTA
systemctl enable --now carbonio-policyd.service
systemctl restart carbonio-mta-sidecar.service
systemctl restart carbonio-postfix.service
Nama service CBPolicyD nya juga bukan cbpolicyd.service seperti yang saya duga awalnya, tapi carbonio-policyd.service. Urutannya penting, enable dan start carbonio-policyd.service dulu sebelum restart sidecar dan postfix.
Cek hasilnya sudah nyambung ke Postfix dengan.
/opt/zextras/common/sbin/postconf smtpd_recipient_restrictions smtpd_end_of_data_restrictions
Harus muncul check_policy_service inet:localhost:10031 di kedua baris itu.
Langkah 7, Perbaiki Grup Internal, Ini yang Paling Sering Terlewat
Quota rate limit yang sudah dibuat nggak akan pernah ke trigger kalau langkah ini dilewat. Policy Default Outbound di CBPolicyD cuma berlaku buat sender yang match grup internal_ips dan internal_domains. Isi bawaan dari schema cuma placeholder contoh yang nggak match jaringan asli siapa pun.
UPDATE policy_group_members SET Member='x.x.x.0/24'
WHERE PolicyGroupID=(SELECT ID FROM policy_groups WHERE Name='internal_ips') AND Member='10.0.0.0/8';
DELETE FROM policy_group_members
WHERE PolicyGroupID=(SELECT ID FROM policy_groups WHERE Name='internal_domains')
AND Member IN ('@example.org','@example.com');
INSERT INTO policy_group_members (PolicyGroupID, Member)
VALUES ((SELECT ID FROM policy_groups WHERE Name='internal_domains'), '@domain.tld');
Langkah 8, Bikin Limit 300 Email per Jam
Quota nya saya tempelkan ke policy Default Outbound yang ID nya 2 secara default.
INSERT INTO quotas (PolicyID, Name, Track, Period, Verdict, Comment)
VALUES (2, 'Outbound rate limit 300 per jam', 'Sender:user@domain', 3600, 'REJECT',
'Rate limit utama, 300 email per jam per akun pengirim');
INSERT INTO quotas_limits (QuotasID, Type, CounterLimit, Comment)
VALUES ((SELECT ID FROM quotas WHERE Name='Outbound rate limit 300 per jam'),
'MessageCount', 300, 'Limit 300 pesan per jam');
Track diset Sender:user@domain biar hitungannya per alamat email pengirim penuh. Ada variasi lain kalau mau basis yang berbeda, Sender:user@ buat per local part saja semua domain digabung, Sender:@domain buat per domain pengirim, atau SenderIP:/24 buat per blok IP pengirim.
Satu hal yang perlu diingat, counter di CBPolicyD itu sifatnya decay atau meluruh pelan pelan seiring waktu, bukan window tetap yang reset mendadak begitu jam berganti.
Langkah 9, Bikin Mekanisme Exclude User
Ini bagian yang paling menarik buat saya pribadi, karena logikanya nggak langsung ketemu di percobaan pertama. Dokumentasi resminya bilang policy dievaluasi berdasarkan priority, angka kecil duluan. Saya awalnya salah baca ini dan mengira sebaliknya, jadi percobaan pertama bikin policy exception malah gagal total.
Pertama, bikin grup khusus buat nampung user yang mau dikecualikan.
INSERT INTO policy_groups (Name, Comment)
VALUES ('rate_limit_excluded', 'Users dikecualikan dari rate limit outbound');
Lalu bikin policy baru dengan priority lebih kecil dari policy rate limit utama yang priority nya 10, dan hubungkan ke grup itu.
INSERT INTO policies (Name, Priority, Description)
VALUES ('Rate Limit Exceptions', 5, 'Bypass rate limit untuk user di grup rate_limit_excluded');
INSERT INTO policy_members (PolicyID, Source, Destination, Comment)
VALUES ((SELECT ID FROM policies WHERE Name='Rate Limit Exceptions'),
'%rate_limit_excluded', NULL, 'Sender masuk grup exclude');
Policy baru ini saya kasih satu baris quota juga, tapi verdictnya OK dan limitnya saya set sangat tinggi, nyaris nggak akan pernah kena. Kuncinya ada di satu flag bernama LastQuota, saya set jadi satu. Begitu quota ini match duluan karena priority lebih kecil, CBPolicyD berhenti ngecek quota lain buat request itu, termasuk quota 300 per jam yang prioritynya lebih besar.
INSERT INTO quotas (PolicyID, Name, Track, Period, Verdict, LastQuota, Comment)
VALUES ((SELECT ID FROM policies WHERE Name='Rate Limit Exceptions'),
'Bypass rate limit', 'Sender:user@domain', 3600, 'OK', 1,
'LastQuota diset satu supaya stop evaluasi quota berikutnya');
INSERT INTO quotas_limits (QuotasID, Type, CounterLimit, Comment)
VALUES ((SELECT ID FROM quotas WHERE Name='Bypass rate limit'),
'MessageCount', 999999999, 'Praktis unlimited');
Buat nambah atau ngurangin user yang dikecualikan, tinggal insert atau delete baris di tabel member grup itu.
-- tambah user
INSERT INTO policy_group_members (PolicyGroupID, Member)
VALUES ((SELECT ID FROM policy_groups WHERE Name='rate_limit_excluded'), 'namauser@domain.tld');
-- hapus user
DELETE FROM policy_group_members
WHERE PolicyGroupID=(SELECT ID FROM policy_groups WHERE Name='rate_limit_excluded')
AND Member='namauser@domain.tld';
Perubahan di tabel member grup biasanya langsung kepakai tanpa perlu restart apa apa. Tapi kalau yang diubah struktur policy baru atau angka priority, wajib restart service CBPolicyD di tiap MTA, karena struktur policy di cache di memory pas service baru nyala.
systemctl restart carbonio-policyd.service
Langkah 10, Testing Tanpa Kirim Ratusan Email Asli
Daripada kirim email beneran berkali kali cuma buat mancing limit kena, saya simulasi langsung ke daemonnya pakai nc ke port 10031, isinya format protokol policy delegation Postfix.
printf "request=smtpd_access_policynprotocol_state=RCPTnprotocol_name=SMTPnhelo_name=test.example.comnqueue_id=QID001nsender=user@domain.tldnrecipient=rcpt@gmail.comnclient_address=x.x.x.100nclient_name=test.example.comninstance=QID001nn" | timeout 3 nc localhost 10031
Hasil yang diharapkan, action=DUNNO kalau masih di bawah limit, atau action=REJECT Policy rejection; Message count quota exceeded kalau sudah kena limit.
Buat ngetes tanpa nunggu counter naik natural, saya ubah langsung nilai counter di database jadi di atas limit, lalu kirim satu request simulasi lagi.
UPDATE quotas_tracking SET Counter=305, LastUpdate=EXTRACT(EPOCH FROM NOW())::bigint
WHERE TrackKey='Sender:user@domain.tld';
Semua skenario itu saya tes satu satu, dan hasilnya sesuai harapan. User biasa kena reject begitu lewat limit, user yang dikecualikan tetap lolos, dan yang paling penting, counter dari dua MTA yang berbeda beneran tergabung jadi satu hitungan, bukan dihitung sendiri sendiri di tiap server.
Penutup
Kalau diringkas, bagian paling krusial dari setup ini bukan di instalasi paketnya, itu bagian paling gampang. Yang bikin lama justru tiga hal, cara Carbonio versi baru ngatur service lewat systemd bukan script lama, grup internal yang harus disesuaikan manual ke network asli, dan arah evaluasi priority policy yang ternyata kebalikan dari asumsi awal saya. Kalau kalian cuma punya satu server, cek dulu tulisan saya yang versi single server, mungkin nggak perlu serepot ini.
Semoga tulisan ini membantu kalian yang kebetulan lagi pegang kasus serupa, biar nggak perlu muter muter nyari sumber errornya seperti saya kemarin.