Email 05 - Internal servers: Postfix
Email Solution · Previous: Virtual machines and OS build · Next: Active Directory integration
The internal pair, DC2-A-VCMSX001 and DC2-B-VCMSX001, is the only place where a server, an application or an appliance may hand over a message. Everything the design promised about who may send what is decided here, in one main.cf: a client has to authenticate, its sender address has to belong to an account in the right Active Directory group, and only some of those accounts may write to the internet. This Article walks through that configuration as it was archived in 2019, including two places where, reading it today, the file does less than its comments say.
Install and a clean slate
Postfix and Dovecot came from the RHEL 7 repository; the paths in main.cf give the version as Postfix 2.10.1. Dovecot is installed on the pair only as the SASL back end (Dovecot authentication). The install notes start, as root, with the packages and then empty both configuration directories after saving the originals:
$ yum install postfix dovecot $ systemctl stop postfix $ systemctl stop dovecot $ mkdir -p /etc/postfix-original $ mkdir -p /etc/dovecot-original $ cp -R /etc/postfix/* /etc/postfix-original/ $ cp -R /etc/dovecot/* /etc/dovecot-original/ $ rm -rf /etc/postfix/* $ rm -rf /etc/dovecot/*
The notes then say that the full configuration "is in attached configuration files" and show only the parts that belong to the cluster. The files evidently came from the previous build: they still carry the distribution's comments, my CFG-ON markers on what I switched on, and, on node B, a value from the other site. The host firewall was opened for the two listeners:
$ firewall-cmd --permanent --add-service=ssh $ firewall-cmd --permanent --add-service=smtp $ firewall-cmd --permanent --add-port=465/tcp $ firewall-cmd --reload
The complete files are Config documents: Postfix main.cf, internal servers and Postfix master.cf, internal servers.
Listeners
| Port | Service in master.cf | TLS | Authentication |
|---|---|---|---|
| 25 | smtp | STARTTLS, mandatory (smtpd_tls_security_level = encrypt) | SASL, offered only inside TLS (smtpd_tls_auth_only = yes) |
| 465 | smtps | TLS from the first byte (smtpd_tls_wrappermode=yes) | SASL |
The design lists both services without saying which clients needed implicit TLS on 465; the clients are the SMTP functions of appliances and applications, and the design sorts them only by whether they can do authentication and TLS at all. The stock submission service on 587 stayed commented out. The smtps service follows the stock template of the package, but it is not simply the template with its comment signs removed: the three $mua_* overrides for the client, helo and sender restrictions, which the commented submission template in the same file still shows, are absent, and content_filter is added:
output 8 lines
smtps inet n - n - - smtpd -o syslog_name=postfix/smtps -o smtpd_tls_wrappermode=yes -o smtpd_sasl_auth_enable=yes -o smtpd_reject_unlisted_recipient=no -o smtpd_recipient_restrictions=permit_sasl_authenticated,reject -o milter_macro_daemon_name=ORIGINATING -o content_filter=bash-postfix-encrypt-filter:
The sixth line matters later. Both listeners bind to every address (inet_interfaces = all, inet_protocols = all), so the service answers on the virtual address 10.12.19.33 and on the address of each node; the design calls that active-active from the service's point of view. Certificates, protocol versions and ciphers are in TLS and certificates.
One requirement does not survive this table. The design promised exceptions for appliances that can do neither TLS nor authentication (FR4). With encrypt as the global security level, port 25 refuses MAIL FROM before STARTTLS from everybody, trusted network or not, and there is no third listener. A client without TLS could not send through the pair as archived.
Identity
| Parameter | Node A | What it does here |
|---|---|---|
myhostname | dc2-a-vcmsx001.adm.example.net | name in the banner and in Received: headers |
mydomain | ad-dc2.example.net | the mail domain of the site, not the DNS domain of the host |
myorigin | $mydomain | domain added to mail posted on the server itself and to addresses without one |
mydestination | $myhostname, localhost.$mydomain, localhost | local delivery for the host only; the mail domain is not in it |
smtpd_banner | $myhostname ESMTP | no software name, no version |
smtp_bind_address | 10.12.19.33 | outgoing connections leave from the virtual address |
smtp_bind_address6 | 2001:db8:a2:b6f::f:1 | the same for IPv6 |
The host is named in the management DNS zone and the mail domain is the Active Directory domain, so mydomain is set by hand instead of being derived from the host name. The mail domain is a virtual mailbox domain (virtual_mailbox_domains = hash:/etc/postfix/virtual_domains), which is why it must not appear in mydestination.
Binding the SMTP client to the virtual address makes both nodes look like one sender to the mailbox server and the relays, whose mynetworks list the address. The backup node does not hold that address, so /etc/sysctl.d/postfix.conf sets net.ipv4.ip_nonlocal_bind and its IPv6 twin to 1; what a bind to an address the node does not own means for mail accepted on the backup node belongs to High availability and key synchronization.
Transport and routing
There are two ways out. The transport table has one line, and it sends the site's own domain to the third internal server:
output 1 line
ad-dc2.example.net smtp:[10.12.19.43]
Everything else follows relayhost = [10.12.19.34], the relay in datacenter A, and when that one cannot be reached smtp_fallback_relay = [10.12.19.35], the relay in datacenter B. The square brackets suppress MX lookups: these are addresses of machines, not mail domains. The pair stores nothing; in the words of the install notes, "this server does not store any email thus there are not mailboxes here".
Two leftovers of the first concept, in which one server per site accepted, filtered and delivered, are still in the files of the pair: dovecot_destination_recipient_limit = 1 in main.cf and the dovecot pipe service in master.cf. No transport on the pair refers to them. The install notes create the vmail account with the remark "not used on this server, for future use". The tables are Config documents: transport, virtual_domains.
Relay control
Postfix evaluates its restriction lists at RCPT TO (the default smtpd_delay_reject = yes was not changed), one list after the other: client, sender, recipient. Inside a list the first restriction that returns a verdict ends that list. A permit lets the message go on to the next list; a reject ends the transaction.
smtpd_client_restrictions= permit_sasl_authenticated, permit_mynetworks, reject smtpd_sender_restrictions= check_sender_access ldap:/etc/postfix/ad_allow_internal_emails.cf, permit_mynetworks, reject_sender_login_mismatch, reject
The client list is the rule "authenticate or be a known host". mynetworks holds three single addresses: the two relays and the mailbox server.
mynetworks = 10.12.19.34/32, 10.12.19.35/32, 10.12.19.43/32
No appliance and no network range is in it, so for everything outside the email system an unauthenticated session ends at the first RCPT TO. The three hosts that are listed may hand over mail without logging in, though still only over TLS. For the mailbox server the reason is in its own main.cf: its relayhost is node A of the pair and its fallback node B, so whatever is written in webmail leaves through the pair. For the relays the Source material gives no reason; their transport table sends non-delivery notifications for the own domain straight to the mailbox server.
The sender list asks the directory whether the envelope sender is the userPrincipalName of an account in the group SMTP_ACCESS. The LDAP table answers OK, and an OK from an access table is a permit. An address that is not in the group gets as far as the final reject, unless the client is one of the three hosts.
The recipient list separates internal from external senders:
smtpd_recipient_restrictions = reject_non_fqdn_recipient, reject_unknown_recipient_domain, check_sender_access ldap:/etc/postfix/ad_allow_external_emails.cf, check_sender_access hash:/etc/postfix/restricted_senders, permit_sasl_authenticated, permit_mynetworks, reject_unauth_destination, reject smtpd_restriction_classes = local_only local_only = check_recipient_access hash:/etc/postfix/virtual_domains, reject
After two sanity checks on the recipient (the second one means that the pair has to be able to resolve external domains), the sender is looked up a second time, now in the group ESMTP_ACCESS. A member is permitted for any recipient. Everybody else meets restricted_senders, which maps every sender of the own domain to the restriction class local_only; the class looks the recipient up in virtual_domains and rejects whatever is not the own domain. The trick is that a sender check sits in the recipient list, where both addresses are known. The four lines after it are reached only by a sender of a foreign domain or by the null sender of a bounce coming from the relays, and the sender list lets both through from the three trusted hosts only.
flowchart TB start["RCPT TO, inside TLS"] c1{"Authenticated or client in mynetworks"} s1{"Sender in SMTP_ACCESS"} s2{"Client in mynetworks"} r0{"Recipient is FQDN and its domain resolves"} r1{"Sender in ESMTP_ACCESS"} r2{"Sender domain in restricted_senders"} r3{"Recipient domain in virtual_domains"} rej["Reject"] ok["Accept, queue, content filter"] start --> c1 c1 -->|"no"| rej c1 -->|"yes"| s1 s1 -->|"yes"| r0 s1 -->|"no"| s2 s2 -->|"no"| rej s2 -->|"yes"| r0 r0 -->|"no"| rej r0 -->|"yes"| r1 r1 -->|"yes"| ok r1 -->|"no"| r2 r2 -->|"no"| ok r2 -->|"yes, class local_only"| r3 r3 -->|"yes"| ok r3 -->|"no"| rej
The diagram shows port 25. Two things have to be said about it.
The first is sender-login matching. The design has a paragraph "Preventing sender spoofing", main.cf sets smtpd_sender_login_maps to an LDAP table that returns the login owning an address, and the comment above the sender list says that a MAIL FROM different from the login is not allowed. But reject_sender_login_mismatch stands third in its list. A sender in SMTP_ACCESS is permitted by the first line and never reaches it; a sender outside the group is rejected by the last line whatever the check says. As the list is ordered, an authenticated account can use the address of any other account in SMTP_ACCESS. The Source material holds no test of this, in either direction.
The second is the sixth line of the smtps service. It is part of the stock template, and it replaces the whole recipient list of main.cf with permit_sasl_authenticated,reject for that service. On port 465 the lookup in ESMTP_ACCESS and the class local_only are therefore not evaluated: whoever passes the client and sender lists may address any domain. main.cf does not set smtpd_relay_restrictions, which Postfix 2.10 introduced, and its default permits authenticated clients too. The separation of internal and external senders was real on port 25 only. I did not see this at the time, and the design does not mention it.
The hardening block
| Setting | Value | Effect |
|---|---|---|
disable_vrfy_command | yes | no address harvesting with VRFY |
smtpd_helo_required | yes | no MAIL FROM before HELO or EHLO |
strict_rfc821_envelopes | yes | envelope addresses must be in angle brackets, without comments |
smtpd_data_restrictions | reject_unauth_pipelining | rejects clients that send commands ahead of the answers |
smtpd_forbidden_commands | CONNECT,GET,POST,USER,PASS | drops sessions that speak HTTP or POP3 to the SMTP port |
smtpd_client_connection_count_limit | 10 | simultaneous connections per client |
smtpd_client_connection_rate_limit | 30 | new connections per client and time unit |
smtpd_client_new_tls_session_rate_limit | 10 | new TLS sessions per client and time unit |
smtpd_recipient_limit | 100 | recipients per message |
message_size_limit | 20971520 | 20 MiB |
header_size_limit | 51200 | 50 KiB of headers |
queue_minfree | 31457280 | 30 MiB, one and a half times the message size limit |
default_process_limit | 100 | processes per service |
append_dot_mydomain | no | incomplete domains are not completed |
The block is the same on the internal pair, the mailbox server and the relays, except for one line: smtpd_client_new_tls_session_rate_limit stands in the TLS block of the internal servers and is not set on the relays. The relays add header checks to it. On the pair the limits of ten connections and thirty connections per time unit apply to every real client, because none of them is in mynetworks; a monitoring system that reports a storm of events through one account is the client most likely to meet them. The protocol lines of the block (!SSLv2, !SSLv3 for smtpd, smtp and lmtp) and smtp_tls_security_level = may for the outgoing side are discussed in TLS and certificates.
Where the content filter hooks in
Both listeners carry -o content_filter=bash-postfix-encrypt-filter:. A message that passed the restrictions is queued and then handed to the pipe service of that name in master.cf, which runs /usr/local/bin/bash-postfix-encrypt-filter.sh under an account of its own, with the envelope sender and one recipient as arguments; bash-postfix-encrypt-filter_destination_recipient_limit=1 in main.cf makes Postfix split a message with several recipients into one run per recipient. Because the filter is attached to the two network listeners and not set globally, what the script injects back through sendmail is not filtered a second time. What the script does with the message is the subject of Automatic email encryption.
Node A and node B
The two nodes should differ in their own name only. diff over the Postfix files of the two archived trees shows this:
| File | Node A | Node B |
|---|---|---|
main.cf, myhostname | dc2-a-vcmsx001.adm.example.net | dc2-b-vcmsx001.adm.example.net |
main.cf, mydomain | ad-dc2.example.net | ad.example.net |
main.cf, certificate and key | files of dc2-a-vcmsx001 | files of dc2-b-vcmsx001 |
main.cf, comment above the recipient list | names ad-dc2.example.net | names ad.example.net |
main.cf, restriction lists | indented with spaces | indented with tabs |
master.cf | no trailing blank | one trailing blank after null_sender= |
| tables and LDAP maps | identical | identical, apart from an empty last line |
Node B carries the mail domain of site 1 in mydomain. It looks like a file copied from the site 1 build with one line missed; the comment above the recipient list names the site 1 domain as well. The consequence is smaller than it looks: virtual_domains, restricted_senders and transport spell out the site 2 domain on both nodes, so mail arriving over SMTP is handled alike. What differs is everything derived from myorigin: mail posted on node B itself and addresses without a domain get @ad.example.net. The install notes of the two nodes are identical in this part and do not show mydomain at all, so the difference was never visible in the documentation.
What I would do differently
- Compare the nodes of a pair with
diffas a step of the build, not seven years later. - Put
reject_sender_login_mismatchin front of the group lookup, so that the first verdict about a sender is whether the address belongs to the login. - Not take the
smtpstemplate as it comes. A listener that replaces the recipient list has to repeat the checks it replaces, or carry no override of its own. - Move the relay decision into
smtpd_relay_restrictions, which exists since Postfix 2.10 and is today the preferred place for it, instead of relying on its default without knowing it. - Decide about FR4 in the open: either a separate listener for the appliances that cannot do TLS, or the sentence in the design that they are not served.
- Remove what is not used on a host: the
dovecottransport limit and pipe service on the pair.