Skip to main content

Enabling cbpolicyd on Zimbra and Reading the Policy Model

Enabling cbpolicyd takes one command. Understanding what it then does takes rather longer, and skipping that step is why so many first rules quietly do nothing. This article covers both: switching the service on, proving Postfix is actually asking it questions, and reading the four tables that decide which messages a rule will ever see.

No rules are written here. That is the next article, and it will make far more sense after this one.

Part of the series that starts with cbpolicyd on Zimbra: An Introduction.

Enable it

On the MTA server, as the zimbra user:

zmprov ms $(zmhostname) +zimbraServiceEnabled cbpolicyd

That is the whole installation. Within a minute or two zmconfigd notices the service is wanted, creates the SQLite database, writes the Postfix configuration and starts the daemon. zmmtactl restart hurries it along if you are impatient.

You do not need to enable the quotas module. zimbraCBPolicydQuotasEnabled defaults to TRUE. A great many tutorials tell you to set it explicitly, which is harmless but unnecessary. The modules that really are off by default are access control, greylisting, SPF and HELO checking, accounting and amavis integration. Each is a separate attribute and each needs an MTA restart:

zmprov ms $(zmhostname) zimbraCBPolicydGreylistingEnabled TRUE
zmmtactl restart

Prove Postfix is consulting it

This is the step to do properly, because everything after it assumes the daemon is in the path. Three checks, in order of what they rule out.

# 1. is the daemon running
zmcbpolicydctl status

# 2. is it listening
ss -lnt | grep 10031

# 3. is Postfix actually asking it anything
postconf | grep check_policy_service

The third is the one that matters. You should see check_policy_service inet:localhost:10031 inside both smtpd_recipient_restrictions and smtpd_end_of_data_restrictions. A running daemon that Postfix never talks to looks identical, from the database side, to a policy that matches nothing.

You can also talk to the socket directly, without sending mail:

printf 'request=smtpd_access_policy\nprotocol_state=RCPT\nprotocol_name=SMTP\nsender=user@example.com\nrecipient=other@example.com\nclient_address=127.0.0.1\n\n' | nc 127.0.0.1 10031

It answers with an action= line. cbpolicyd may want more attributes than those six before it returns a meaningful verdict, so treat this as proof of connectivity rather than as a policy test.

Turn the logging up before you need it

At the default log level of 3 you see almost nothing about why a decision was made. For the period while you are building rules, raise it:

zmprov ms $(zmhostname) zimbraCBPolicydLogLevel 4
zmmtactl restart

tail -f /opt/zimbra/log/cbpolicyd.log

Put it back to 3 when you are done. Level 4 is genuinely noisy on a busy server.

The policy model

Four tables decide whether a rule ever fires. They are worth learning as a unit, because each one is useless without the others.

policy_groups named sets, e.g. internal_domains
|
+-- policy_group_members what is in the set: @example.com, 10.0.0.0/8
|
policies the rule container: name, priority
|
+-- policy_members who this policy applies to: Source -> Destination
|
+-- quotas / access_control / greylisting ... what actually happens

Read it from the bottom. A module row such as a quota does the work, and it points at a policy. The policy is only a container with a name and a priority; on its own it does nothing at all. What the policy matches lives in policy_members, as a Source and a Destination. Those two fields can name a group, whose contents live in policy_group_members.

Break any link in that chain and the result is the same: silence.

Look at what you already have

sqlite3 /opt/zimbra/data/cbpolicyd/db/cbpolicyd.sqlitedb
.headers on
.mode box

The stock policies:

SELECT ID, Name, Priority, Disabled FROM policies ORDER BY Priority, ID;

A fresh Zimbra database has four, and they correspond to the four directions mail can travel:

IDNamePriorityMatches
1Default0everything
2Default Outbound10internal sender to external recipient
3Default Inbound10external sender to internal recipient
4Default Internal20internal to internal

Then how each one matches, and what the groups actually contain:

SELECT p.ID, p.Name, p.Priority, m.Source, m.Destination
FROM policies p
LEFT JOIN policy_members m ON m.PolicyID = p.ID
ORDER BY p.Priority, p.ID;

SELECT g.ID, g.Name, quote(m.Member) AS Member, m.Disabled
FROM policy_groups g
LEFT JOIN policy_group_members m ON m.PolicyGroupID = g.ID
ORDER BY g.ID;

The quote() wrapper on that second query is deliberate. It shows a NULL as NULL and an empty string as '', which a plain SELECT renders identically as blank space. A NULL member can behave as a wildcard, so telling the two apart matters.

Priority runs the opposite way to the words

Policies are evaluated in ascending numeric order. The upstream documentation is blunt about the confusion:

"Higher priority" in everyday language can be ambiguous. In PolicyD, lower numeric values are evaluated first.

So Default at priority 0 is considered before Default Internal at 20.

More importantly, matching does not stop at the first hit. cbpolicyd collects every policy that matches the transaction, ordered by priority, and the modules work through them in that order. A general policy and a specific one both apply, rather than the specific one simply winning. That is why a quota on Default and another on Default Outbound will both count the same outbound message.

Fix the shipped defaults before trusting anything

This is the single most common reason a first rule appears to be ignored. The stock internal_domains group ships with cluebringer's example values:

internal_domains @example.com
internal_domains @example.org

Neither is your domain, so Default Inbound, Default Outbound and Default Internal match nothing real until you fix it. Worse, if mail genuinely arrives from a real example.com sender, your server will classify it as internal.

List your actual domains and add them:

zmprov gad
INSERT INTO policy_group_members (PolicyGroupID, Member, Disabled)
VALUES ((SELECT ID FROM policy_groups WHERE Name = 'internal_domains'),
'@yourdomain.com', 0);

DELETE FROM policy_group_members
WHERE PolicyGroupID = (SELECT ID FROM policy_groups WHERE Name = 'internal_domains')
AND Member IN ('@example.com', '@example.org');

Check internal_ips in the same pass. It commonly ships with a blank member row, and a NULL member acting as a wildcard would make the group match every client address rather than your own ranges.

Groups are cached, so restart after changing them:

zmcbpolicydctl restart

Source and destination syntax

When you do start writing policy_members rows, these are the forms Source accepts. Destination takes the address, domain and group forms but not the network or SASL ones.

FormExampleMeaning
wildcardanyanything
addressalice@example.comone address
domain@example.comany address at that domain
network192.0.2.0/24client IP range
SASL user$alice@example.comauthenticated user
any SASL$*any authenticated session
no SASL$-unauthenticated sessions only
reverse DNS.example.comclient rDNS suffix
group%internal_domainsa named group
negation!%internal_domainsanything not in it

The $* and $- forms are more useful than they look. A quota scoped to $* applies only to mail your own users submitted after authenticating, which is exactly the population you want to rate limit when you are guarding against a compromised password.

Back up before you change anything

Every change from here on is a hand-written INSERT or DELETE against a live database, and there is no undo:

cp /opt/zimbra/data/cbpolicyd/db/cbpolicyd.sqlitedb \
/opt/zimbra/data/cbpolicyd/db/cbpolicyd.sqlitedb.bak-$(date +%F)

Get into the habit now rather than after the first mistake. Restoring is a file copy and a zmcbpolicydctl restart.

A checklist before writing your first rule

  1. zmcbpolicydctl status shows it running.
  2. postconf | grep check_policy_service shows Postfix consulting localhost:10031.
  3. zimbraCBPolicydLogLevel is 4 while you work.
  4. internal_domains contains your real domains and not @example.com.
  5. internal_ips has no blank or NULL member.
  6. You have a dated backup of the database.

Next in this series

With the model understood and the defaults corrected, cbpolicyd Quotas writes the first real rule: a limit on how many messages a sender can send in a given period, which is the thing cbpolicyd is genuinely better at than anything else on a Zimbra server.

When something does not behave, Troubleshooting cbpolicyd on Zimbra is the order to check things in.