Почтовый сервер на FreeBSD 15.1: Exim и Dovecot
Почтовый сервер на FreeBSD 15.1: Exim и Dovecot
Самостоятельная инструкция для одного сервера: Exim принимает SMTP и submission, Dovecot предоставляет IMAPS, а доставка в Maildir выполняется через локальный LMTP-сокет. Пользователи и алиасы хранятся в файлах, SQL не требуется. Почта, очередь и дополнительные данные размещаются на отдельной UFS в /opt. Сертификат выпускается после подготовки конфигураций: до этого сетевые почтовые службы не запускаются.
Все имена и адреса ниже — примеры. Заменить example.org и example.net своими доменами, mail.example.org — своим FQDN, 192.0.2.10 — реальным публичным IPv4, 192.168.1.12 — адресом сервера в LAN. 192.0.2.10 зарезервирован для документации и в интернете не работает. Команды для чистой системы выполняются от root в /bin/sh; существующие конфигурации сначала сравнить, не заменять вслепую. Проверенная основа: FreeBSD 15.1, Exim 4.100, Dovecot 2.4.5. Для других веток Dovecot синтаксис отличается.
1. Схема и требования
Интернет → TCP 25 / STARTTLS → Exim Клиент → TCP 465 TLS или 587 STARTTLS + AUTH → Exim Exim → LMTP Unix socket → Dovecot → /opt/mail/vhosts/domain/user/Maildir Exim → AUTH Unix socket → Dovecot → файловый каталог пользователей Клиент → TCP 993 TLS → Dovecot SnappyMail (необязательно) → те же IMAPS и SMTP AUTH
- Выделенный пустой диск da1; системный диск — другой диск. Тип ФС — UFS.
- Статический адрес сервера и действующий маршрут по умолчанию. При NAT пробросить TCP 25, 465, 587, 993 на сервер. Для SnappyMail понадобится HTTPS 443 или отдельный обратный прокси.
- Провайдер должен разрешать исходящий и входящий TCP 25. PTR публичного IP заказывается у владельца адреса: mail.example.org. Прямая A-запись должна возвращать тот же IP.
- Основной вариант IPv4. AAAA не публиковать, пока IPv6 не настроен полностью; в приведенном Exim IPv6 выключен.
- Не публиковать MX нового сервера до запуска и проверки. Смена MX переключает новые письма, но не переносит старую почту.
- Антиспам и фаервол не входят в базовую последовательность. Rspamd — отдельный этап, ClamAV описан ниже.
2. Проверить систему без изменений
id freebsd-version -kru uname -m hostname -f ifconfig -a netstat -rn cat /etc/resolv.conf mount -p df -hT swapinfo -h geom disk list da1 gpart show -p gpart show -p da1 glabel status fstyp /dev/da1 file -s /dev/da1 sockstat -46l ls -ld /opt cat /etc/fstab
При необходимости настроить имя и шлюз, подставив реальные параметры сети. Если интерфейс уже настроен, не применять ifconfig повторно через удалённый сеанс без необходимости.
sysrc hostname=mail.example.org hostname mail.example.org sysrc defaultrouter=192.168.1.1 # Только если в работающей таблице отсутствует default route: route add default 192.168.1.1
3. Однократно создать UFS на пустом da1
ВНИМАНИЕ: gpart create и newfs — разрушающие команды. На диске с существующими данными их повторять нельзя. Проверить модель, размер, серийный номер, разделы, метки, mount, swap и GEOM-зависимости. Отсутствие распознанной ФС не доказывает, что диск пуст. /opt не должен содержать файлов, которые скроются после монтирования.
Для нового диска, который должен быть полностью нулевым, дополнительная проверка чтением всех байтов:
disk_bytes=$(diskinfo /dev/da1 | awk '{print $3}')
test -n "$disk_bytes" && test "$disk_bytes" -gt 0 || exit 1
cmp -s -n "$disk_bytes" /dev/da1 /dev/zero
printf 'cmp status: %s\n' "$?"
0 означает нулевое содержимое, 1 — ненулевые данные, больше 1 — ошибку проверки. В двух последних случаях остановиться и разобраться. На уже созданной UFS результат 1 нормален и не является поводом форматировать её.
Только после проверки действительно пустого выделенного диска:
gpart create -s gpt da1 gpart add -a 1m -t freebsd-ufs -l mailopt da1 gpart show -pl da1 newfs -U -L mailopt /dev/gpt/mailopt install -d -o root -g wheel -m 0755 /opt mount -o rw,noatime /dev/gpt/mailopt /opt df -hT /opt
В /etc/fstab добавить ровно одну запись, предварительно исключив дубликаты:
/dev/gpt/mailopt /opt ufs rw,noatime 2 2
При повторной установке существующую UFS проверяют и монтируют, не создают заново.
4. Пакеты, пользователи и каталоги
pkg update pkg install -n exim dovecot24 swaks ca_root_nss acme.sh # После проверки плана: pkg install -y exim dovecot24 swaks ca_root_nss acme.sh sysrc exim_enable=NO dovecot_enable=NO sysrc sendmail_enable=NONE sendmail_submit_enable=NO sysrc sendmail_outbound_enable=NO sendmail_msp_queue_enable=NO service sendmail onestop /usr/local/sbin/exim -bV /usr/local/sbin/dovecot --version pw usershow mailnull pw groupshow mail pw groupshow dovecot
Ошибку «служба sendmail не запущена» можно принять только после проверки, что другой SMTP-процесс не занимает порт 25. Существующий рабочий почтовый сервер нельзя останавливать этой последовательностью без миграции.
В пакете Exim должны присутствовать OpenSSL, Dovecot authenticator, LMTP и Content_Scanning для необязательного ClamAV. Проверить, что UID и GID 5000 свободны. Если заняты, выбрать другие и согласованно заменить во всех дальнейших настройках.
pw usershow -u 5000 pw groupshow -g 5000 # Следующие две команды только если идентификаторы свободны: pw groupadd vmail -g 5000 pw useradd vmail -u 5000 -g vmail -d /opt/mail -s /usr/sbin/nologin -w no install -d -o root -g wheel -m 0755 /opt/mail install -d -o root -g vmail -m 0750 /opt/mail/vhosts install -d -o mailnull -g mail -m 0750 /opt/mail/exim-spool /var/log/exim install -d -o root -g mail -m 0750 /usr/local/etc/exim install -d -o root -g dovecot -m 0750 /usr/local/etc/virtual-mail install -d -o root -g mail -m 0750 /usr/local/etc/exim/dkim /usr/local/etc/mail-certs install -d -o root -g wheel -m 0755 /etc/rc.conf.d
Создать файлы, если они отсутствуют; не обнулять уже существующий каталог:
touch /usr/local/etc/virtual-mail/users
chown root:dovecot /usr/local/etc/virtual-mail/users
chmod 0640 /usr/local/etc/virtual-mail/users
for name in domains aliases mailboxes sender_owners; do
touch /usr/local/etc/exim/$name
chown root:mail /usr/local/etc/exim/$name
chmod 0640 /usr/local/etc/exim/$name
done
В /etc/rc.conf.d/exim сохранить:
required_dirs="/opt/mail/exim-spool /opt/mail/vhosts"
В /etc/rc.conf.d/dovecot:
required_dirs="/opt/mail/vhosts"
5. Полная конфигурация Exim
Сохранить следующий текст как /usr/local/etc/exim/configure, владелец root:mail, режим 0640. Это конфигурация без ClamAV; антивирус добавляется отдельным этапом. На 465/587 обязательны TLS и AUTH; на 25 AUTH не предлагается. Межсерверный SMTP 25 использует STARTTLS по возможности. Relay без аутентификации запрещён, включая localhost и LAN. Получатели обслуживаемых доменов проверяются до приема письма; авторизованный пользователь может отправлять только со своих адресов и алиасов.
# FreeBSD 15.1 / Exim / virtual users / Dovecot LMTP and AUTH.
# Render placeholders, validate with exim -C ... -bV, then install as
# /usr/local/etc/exim/configure. Do not start before certificates exist.
# Runtime identity in the official FreeBSD package is mailnull:mail.
primary_hostname = mail.example.org
qualify_domain = example.org
keep_environment =
local_interfaces = 127.0.0.1 : 192.168.1.12
disable_ipv6 = true
domainlist local_domains = @ : /usr/local/etc/exim/domains
spool_directory = /opt/mail/exim-spool
log_file_path = /var/log/exim/%slog
never_users = root
message_size_limit = 50M
daemon_smtp_ports = 25 : 465 : 587
tls_on_connect_ports = 465
tls_advertise_hosts = *
tls_certificate = /usr/local/etc/mail-certs/fullchain.pem
tls_privatekey = /usr/local/etc/mail-certs/privkey.pem
# This option requires an OpenSSL build (the default FreeBSD port).
# Preserve the standard safety flags while disabling TLS 1.0 and 1.1.
openssl_options = +no_sslv2 +no_sslv3 +single_dh_use +no_ticket +no_renegotiation +no_tlsv1 +no_tlsv1_1
tls_require_ciphers = HIGH:!aNULL:!eNULL:!MD5:!RC4:!3DES
acl_smtp_auth = acl_check_auth
acl_smtp_mail = acl_check_mail
acl_smtp_rcpt = acl_check_rcpt
acl_smtp_data = acl_check_data
acl_smtp_vrfy = deny
acl_smtp_expn = deny
begin acl
acl_check_auth:
deny
condition = ${if !inlist{$received_port}{465:587}{yes}{no}}
message = Authentication is available only on submission ports
accept
encrypted = *
deny
message = TLS is required before authentication
acl_check_mail:
deny
condition = ${if inlist{$received_port}{465:587}{yes}{no}}
!encrypted = *
message = TLS is required on submission ports
deny
condition = ${if inlist{$received_port}{465:587}{yes}{no}}
!authenticated = *
message = Authentication is required on submission ports
deny
authenticated = *
condition = ${if !inlisti{${lc:$authenticated_id}}{${lookup{${lc:$sender_address}}lsearch{/usr/local/etc/exim/sender_owners}{$value}{}}}{yes}{no}}
message = Envelope sender is not authorized for this account
accept
acl_check_rcpt:
# Validate hosted recipients even when the sender is authenticated.
deny
domains = +local_domains
!verify = recipient
message = Unknown local recipient
accept
authenticated = *
accept
domains = +local_domains
# There is deliberately no localhost/LAN relay exemption.
deny
message = Relay not permitted
acl_check_data:
# Only submitted authenticated mail is subjected to sender ownership.
# Raw From preserves encoded display names containing commas.
deny
authenticated = *
!verify = header_syntax
message = Invalid message header syntax
warn
authenticated = *
set acl_m_from_addresses = ${addresses:>:$rh_from:}
deny
authenticated = *
condition = ${if !eq{${listcount:$acl_m_from_addresses}}{1}{yes}{no}}
message = A single valid From address is required
deny
authenticated = *
condition = ${if !inlisti{${lc:$authenticated_id}}{${lookup{${lc:$acl_m_from_addresses}}lsearch{/usr/local/etc/exim/sender_owners}{$value}{}}}{yes}{no}}
message = Header From address is not authorized for this account
accept
begin routers
service_hostname_postmaster:
driver = redirect
domains = @
local_parts = root : postmaster
data = alice@example.org
forbid_file
forbid_pipe
virtual_aliases:
driver = redirect
domains = +local_domains
data = ${lookup{${lc:$local_part@$domain}}lsearch{/usr/local/etc/exim/aliases}{$value}}
forbid_file
forbid_pipe
virtual_mailboxes:
driver = accept
domains = +local_domains
condition = ${lookup{${lc:$local_part@$domain}}lsearch{/usr/local/etc/exim/mailboxes}{yes}{no}}
transport = dovecot_lmtp
no_more
cannot_route_message = Unknown local recipient
remote_dns:
driver = dnslookup
domains = ! +local_domains
transport = remote_smtp
ignore_target_hosts = 0.0.0.0 : 127.0.0.0/8 : ::1
no_more
begin transports
remote_smtp:
driver = smtp
dkim_domain = ${lookup{${lc:$sender_address_domain}}lsearch,ret=key{/usr/local/etc/exim/domains}{$value}{}}
dkim_selector = mail
dkim_private_key = ${if exists{/usr/local/etc/exim/dkim/$dkim_domain.key}{/usr/local/etc/exim/dkim/$dkim_domain.key}{0}}
dkim_hash = sha256
dkim_canon = relaxed
dkim_strict = true
# Opportunistic STARTTLS is the normal Exim default for remote delivery.
tls_verify_certificates = system
dovecot_lmtp:
driver = lmtp
socket = /var/run/dovecot/lmtp-exim
user = mailnull
group = mail
batch_max = 20
# LMTP RCPT contains the full canonical local_part@domain.
begin retry
* * F,2h,15m; G,16h,1h,1.5; F,4d,6h
begin rewrite
begin authenticators
dovecot_plain:
driver = dovecot
public_name = PLAIN
server_socket = /var/run/dovecot/auth-exim
server_set_id = $auth1
server_advertise_condition = ${if and{{def:tls_in_cipher}{inlist{$received_port}{465:587}}}{yes}{no}}
dovecot_login:
driver = dovecot
public_name = LOGIN
server_socket = /var/run/dovecot/auth-exim
server_set_id = $auth1
server_advertise_condition = ${if and{{def:tls_in_cipher}{inlist{$received_port}{465:587}}}{yes}{no}}
6. Полная конфигурация Dovecot 2.4
Сохранить как /usr/local/etc/dovecot/dovecot.conf, root:wheel, 0644. Не подключать одновременно пакетное дерево conf.d с повторяющимися настройками. Важен type = auth-legacy у сокета Exim в Dovecot 2.4. TCP 143 и стандартный открытый LMTP-сокет отключены.
dovecot_config_version = 2.4.4
dovecot_storage_version = 2.4.4
# FreeBSD 15.1, installed Dovecot CE 2.4.5_2, Exim on the same host.
# vmail has UID/GID 5000. Mail hostname is mail.example.org.
# Install as /usr/local/etc/dovecot/dovecot.conf, root:wheel 0644.
# Keep dovecot_enable=NO until certificates exist and all checks pass.
# No include of the package's example conf.d tree: this is a complete config.
base_dir = /var/run/dovecot
state_dir = /var/db/dovecot
listen = 0.0.0.0
protocols = imap lmtp
hostname = mail.example.org
postmaster_address = postmaster@example.org
# One canonical login per mailbox; preserve the domain and normalize case.
auth_mechanisms = plain login
auth_allow_cleartext = no
auth_username_format = %{user | lower}
auth_verbose = yes
# One service UID/GID for all virtual mailboxes.
first_valid_uid = 5000
last_valid_uid = 5000
first_valid_gid = 5000
last_valid_gid = 5000
mail_driver = maildir
mail_path = ~/Maildir
mail_uid = vmail
mail_gid = vmail
# Same file provides passdb and an existence-checking userdb. In particular,
# do not replace userdb with a static backend that accepts arbitrary names.
# File mode/owner: root:dovecot 0640. All entries use full user@domain.
passdb passwd-file {
passwd_file_path = /usr/local/etc/virtual-mail/users
}
userdb passwd-file {
passwd_file_path = /usr/local/etc/virtual-mail/users
}
# IMAP access is TLS-only on TCP 993. Never run the service before the last
# certificate-creation/installation step. These settings do not issue a cert.
ssl = required
ssl_min_protocol = TLSv1.2
ssl_server_cert_file = /usr/local/etc/mail-certs/fullchain.pem
ssl_server_key_file = /usr/local/etc/mail-certs/privkey.pem
service imap-login {
inet_listener imap {
port = 0
}
inet_listener imaps {
port = 993
ssl = yes
}
}
# Exim's Dovecot authenticator currently needs auth-legacy with Dovecot 2.4.
# root creates the socket for Exim; mailnull is the FreeBSD Exim account.
service auth {
unix_listener auth-exim {
type = auth-legacy
user = mailnull
group = mail
mode = 0600
}
# Local LMTP runs as vmail and uses this userdb socket for lookups.
unix_listener auth-userdb {
user = vmail
group = vmail
mode = 0600
}
}
# Only the explicitly named Exim socket may receive mail. Disable the
# package/default LMTP listener, whose default mode is otherwise 0666.
service lmtp {
user = vmail
unix_listener lmtp {
mode = 0
}
unix_listener lmtp-exim {
user = mailnull
group = mail
mode = 0600
}
}
# Standard folders let desktop clients and future SnappyMail agree on names.
# Junk may be added later with an optional spam workflow.
namespace inbox {
inbox = yes
separator = /
mailbox Drafts {
auto = subscribe
special_use = \Drafts
}
mailbox Sent {
auto = subscribe
special_use = \Sent
}
mailbox Trash {
auto = subscribe
special_use = \Trash
}
mailbox Archive {
auto = subscribe
special_use = \Archive
}
}
# Official references checked while preparing this template:
# https://doc.dovecot.org/2.4.4/howto/sasl/exim.html
# https://doc.dovecot.org/2.4.5/core/config/auth/databases/passwd_file.html
# https://doc.dovecot.org/2.4.5/core/config/delivery/lmtp.html
# https://doc.dovecot.org/2.4.4/core/config/namespaces.html
# https://doc.dovecot.org/latest/core/config/ssl.html
7. Управление доменами, ящиками, паролями и алиасами
Одна каноническая запись users имеет формат:
alice@example.org:{BLF-CRYPT}REPLACE_WITH_REAL_HASH:5000:5000::/opt/mail/vhosts/example.org/alice::
Это лишь пояснение формата, не рабочий пароль. Пароли вводятся интерактивно через doveadm; передавать их аргументом командной строки не следует. Полный адрес user@domain — логин и идентификатор ящика; alice@example.org и alice@example.net — разные пользователи.
Следующий готовый скрипт сохранить как /usr/local/sbin/mail-user (root:wheel, 0750). Он использует lockf, проверяет каталог, заменяет отдельные файлы атомарным rename и согласованно пересоздаёт recipient/sender maps. Алиас направляется только на существующего канонического пользователя того же домена и одновременно разрешает отправку с этого адреса. Удаление ящиков намеренно не автоматизировано.
#!/bin/sh
# mail-user: conservative file-catalog administration for Exim + Dovecot.
# Passwords are entered only into doveadm's terminal prompt.
# No delete operation, no automatic backups, no service startup.
set -eu
PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin
export PATH
LC_ALL=C
export LC_ALL
umask 077
STATE=/usr/local/etc/virtual-mail
USERS=$STATE/users
EXIMDIR=/usr/local/etc/exim
DOMAINS=$EXIMDIR/domains
ALIASES=$EXIMDIR/aliases
MAILBOXES=$EXIMDIR/mailboxes
OWNERS=$EXIMDIR/sender_owners
MAILROOT=/opt/mail/vhosts
EXIM=/usr/local/sbin/exim
die() { printf '%s\n' "$*" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || die "Run with sudo."
[ -d "$STATE" ] && [ -d "$EXIMDIR" ] || die "Install the mail configuration directories first."
case "${1-}" in
--locked) shift ;;
*) exec /usr/bin/lockf -t 0 "$STATE/.mail-user.lock" "$0" --locked "$@" ;;
esac
[ "$#" -ge 1 ] || die "Usage: mail-user add-domain DOMAIN | add-user ADDRESS | set-password ADDRESS | add-alias ALIAS CANONICAL"
action=$1
shift
PASSWORD_HASH_FILE=
case "$action" in
add-domain) [ "$#" -eq 1 ] || die "add-domain takes one domain." ;;
add-user|set-password)
if [ "$#" -eq 3 ] && [ "$2" = --hash-file ]; then
PASSWORD_HASH_FILE=$3
elif [ "$#" -ne 1 ]; then
die "Use ADDRESS, optionally followed by --hash-file ROOT_ONLY_FILE."
fi
;;
add-alias) [ "$#" -eq 2 ] || die "add-alias takes ALIAS CANONICAL." ;;
*) die "Unknown operation." ;;
esac
/sbin/mount -p | awk '$2 == "/opt" && $3 == "ufs" { found = 1 } END { exit !found }' ||
die "/opt must be mounted as UFS before mail administration."
EXIMGROUP=$("$EXIM" -bP exim_group | awk -F ' = ' 'NR == 1 { print $2 }')
[ -n "$EXIMGROUP" ] || die "Cannot determine the Exim runtime group."
pw groupshow "$EXIMGROUP" >/dev/null || die "The Exim runtime group does not exist."
pw groupshow dovecot >/dev/null || die "The dovecot group does not exist."
VUID=$(id -u vmail)
VGID=$(id -g vmail)
for file in "$USERS" "$DOMAINS" "$ALIASES"; do
[ ! -L "$file" ] || die "Refusing a symlink in the mail catalog."
if [ ! -e "$file" ]; then
: > "$file"
case "$file" in
"$USERS") chown root:dovecot "$file" ;;
*) chown root:"$EXIMGROUP" "$file" ;;
esac
chmod 0640 "$file"
fi
[ -f "$file" ] || die "The mail catalog contains a non-regular file."
done
work=$(mktemp -d "$STATE/.mail-user.XXXXXX")
pending=
cleanup() {
[ -z "$pending" ] || rm -f "$pending"
rm -f "$work/users" "$work/domains" "$work/aliases" "$work/mailboxes" "$work/owners" "$work/user-keys" "$work/alias-records"
rmdir "$work" 2>/dev/null || true
}
trap cleanup EXIT
trap 'exit 1' HUP INT TERM
normalize() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; }
validate_domain() {
vd=$1
[ "${#vd}" -le 253 ] || die "Domain is too long."
case "$vd" in
''|.*|*.|*..*|*[!a-z0-9.-]*) die "Invalid domain: $vd" ;;
esac
oldifs=$IFS
IFS=.
set -- $vd
IFS=$oldifs
for label do
[ "${#label}" -ge 1 ] && [ "${#label}" -le 63 ] || die "Invalid domain label."
case "$label" in -*|*-) die "Invalid domain label." ;; esac
done
}
validate_address() {
va=$1
case "$va" in
*@*) ;;
*) die "Use a full email address." ;;
esac
vl=${va%%@*}
vd=${va#*@}
case "$vd" in *@*) die "Invalid email address." ;; esac
[ "${#vl}" -ge 1 ] && [ "${#vl}" -le 64 ] || die "Invalid local part length."
case "$vl" in
[a-z0-9]*) ;;
*) die "Local part must start with a letter or digit." ;;
esac
case "$vl" in *[!a-z0-9._+-]*|*.|*..*) die "Invalid local part." ;; esac
validate_domain "$vd"
}
has_domain() { grep -Fx "$1" "$DOMAINS" >/dev/null; }
has_user() { awk -F: -v key="$1" '$0 !~ /^[[:space:]]*(#|$)/ && $1 == key { found = 1 } END { exit !found }' "$USERS"; }
has_alias() { awk -F: -v key="$1" '$0 !~ /^[[:space:]]*(#|$)/ { gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1); if ($1 == key) found = 1 } END { exit !found }' "$ALIASES"; }
replace_file() {
source=$1
destination=$2
group=$3
pending=$(mktemp "$(dirname "$destination")/.mail-user-file.XXXXXX")
cat "$source" > "$pending"
chown root:"$group" "$pending"
chmod 0640 "$pending"
mv -f "$pending" "$destination"
pending=
}
# Validate the existing canonical catalog before deriving Exim recipient maps.
validate_catalog() {
awk -F: -v expected_uid="$VUID" -v expected_gid="$VGID" -v mailroot="$MAILROOT" '
/^[[:space:]]*(#|$)/ { next }
NF != 8 || $1 == "" || $2 !~ /^\{[A-Z0-9-]+\}/ || seen[$1]++ { exit 1 }
{
split($1, address, "@")
if ($3 != expected_uid || $4 != expected_gid || $6 != mailroot "/" address[2] "/" address[1]) exit 1
}
{ print $1 }
' "$USERS" > "$work/user-keys" || die "Malformed or duplicate canonical user record."
while IFS= read -r key; do
validate_address "$key"
[ "$(normalize "$key")" = "$key" ] || die "Canonical addresses must be lowercase."
has_domain "${key#*@}" || die "A canonical user's domain is missing from domains."
done < "$work/user-keys"
awk -F: '
/^[[:space:]]*(#|$)/ { next }
NF != 2 { exit 1 }
{
gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1)
gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2)
if ($1 == "" || $2 == "" || seen[$1]++) exit 1
print $1 ":" $2
}
' "$ALIASES" > "$work/alias-records" || die "Malformed or duplicate alias record."
while IFS=: read -r alias target; do
validate_address "$alias"
validate_address "$target"
[ "$(normalize "$alias")" = "$alias" ] && [ "$(normalize "$target")" = "$target" ] ||
die "Alias addresses must be lowercase."
[ "${alias#*@}" = "${target#*@}" ] || die "An alias crosses domains."
has_user "$target" || die "An alias target is not an existing canonical user."
if has_user "$alias"; then die "An alias conflicts with a canonical user."; fi
done < "$work/alias-records"
}
regenerate_maps() {
validate_catalog
awk '{ print $0 ": yes" }' "$work/user-keys" > "$work/mailboxes"
awk '{ print $0 ": " $0 }' "$work/user-keys" > "$work/owners"
awk -F: '{ print $1 ": " $2 }' "$work/alias-records" >> "$work/owners"
replace_file "$work/mailboxes" "$MAILBOXES" "$EXIMGROUP"
replace_file "$work/owners" "$OWNERS" "$EXIMGROUP"
}
new_password_hash() {
if [ -n "$PASSWORD_HASH_FILE" ]; then
case "$PASSWORD_HASH_FILE" in /*) ;; *) die "Hash file path must be absolute." ;; esac
[ -f "$PASSWORD_HASH_FILE" ] && [ ! -L "$PASSWORD_HASH_FILE" ] || die "Invalid hash file."
[ "$(stat -f %u "$PASSWORD_HASH_FILE")" -eq 0 ] || die "Hash file must belong to root."
case "$(stat -f %Lp "$PASSWORD_HASH_FILE")" in 400|600) ;; *) die "Hash file must have mode 0400 or 0600." ;; esac
NEW_HASH=$(cat "$PASSWORD_HASH_FILE")
else
[ -t 0 ] || die "Run interactively in PuTTY to enter the mailbox password."
DOVEADM=$(command -v doveadm) || die "doveadm is unavailable."
if "$DOVEADM" -O pw -l | tr ' ' '\n' | grep -Fx ARGON2ID >/dev/null; then
NEW_HASH=$("$DOVEADM" -O pw -s ARGON2ID)
else
NEW_HASH=$("$DOVEADM" -O pw -s BLF-CRYPT -r 12)
fi
fi
case "$NEW_HASH" in
\{ARGON2ID\}*|\{BLF-CRYPT\}*) ;;
*) die "doveadm did not return a supported password hash." ;;
esac
case "$NEW_HASH" in *:*|*'
'*) die "Invalid password hash format." ;; esac
}
validate_catalog
case "$action" in
add-domain)
domain=$(normalize "$1")
validate_domain "$domain"
if has_domain "$domain"; then die "Domain already exists."; fi
cat "$DOMAINS" > "$work/domains"
printf '%s\n' "$domain" >> "$work/domains"
replace_file "$work/domains" "$DOMAINS" "$EXIMGROUP"
regenerate_maps
printf 'Domain added: %s\n' "$domain"
;;
add-user)
address=$(normalize "$1")
validate_address "$address"
domain=${address#*@}
localpart=${address%%@*}
has_domain "$domain" || die "Add the domain first."
if has_user "$address" || has_alias "$address"; then die "Address already exists."; fi
new_password_hash
home=$MAILROOT/$domain/$localpart
install -d -o root -g vmail -m 0750 "$MAILROOT" "$MAILROOT/$domain"
install -d -o vmail -g vmail -m 0750 "$home" "$home/Maildir" "$home/Maildir/cur" "$home/Maildir/new" "$home/Maildir/tmp"
cat "$USERS" > "$work/users"
printf '%s:%s:%s:%s::%s::\n' "$address" "$NEW_HASH" "$VUID" "$VGID" "$home" >> "$work/users"
replace_file "$work/users" "$USERS" dovecot
unset NEW_HASH
regenerate_maps
printf 'Mailbox added: %s\n' "$address"
;;
set-password)
address=$(normalize "$1")
validate_address "$address"
has_user "$address" || die "No such canonical user."
new_password_hash
# awk receives the hash through a root-only temporary input file,
# never through a command-line argument or an environment variable.
printf '%s\n' "$NEW_HASH" > "$work/users"
unset NEW_HASH
awk -F: -v OFS=: -v key="$address" '
NR == FNR { password = $0; next }
$1 == key { $2 = password }
{ print }
' "$work/users" "$USERS" > "$work/domains"
replace_file "$work/domains" "$USERS" dovecot
regenerate_maps
printf 'Password changed: %s\n' "$address"
;;
add-alias)
alias=$(normalize "$1")
target=$(normalize "$2")
validate_address "$alias"
validate_address "$target"
[ "${alias#*@}" = "${target#*@}" ] || die "Alias and canonical user must be in the same domain."
has_user "$target" || die "Target must be an existing canonical user."
if has_user "$alias" || has_alias "$alias"; then die "Alias address already exists."; fi
cat "$ALIASES" > "$work/aliases"
printf '%s: %s\n' "$alias" "$target" >> "$work/aliases"
replace_file "$work/aliases" "$ALIASES" "$EXIMGROUP"
regenerate_maps
printf 'Alias added: %s -> %s (including permission to send)\n' "$(normalize "$1")" "$(normalize "$2")"
;;
esac
# Dovecot's default authentication cache is disabled. If cache is enabled
# later, flush it after password changes; do not start services here.
exit 0
chown root:wheel /usr/local/sbin/mail-user
chmod 0750 /usr/local/sbin/mail-user
/usr/local/sbin/exim -bV
mail-user add-domain example.org
mail-user add-domain example.net
mail-user add-user alice@example.org
mail-user add-user alice@example.net
for domain in example.org example.net; do
for alias in root webmaster admin postmaster; do
mail-user add-alias "$alias@$domain" "alice@$domain"
done
done
Каждый add-user запрашивает новый пароль дважды. Во время первоначального наполнения службы ещё выключены. Для последующего обслуживания:
mail-user set-password alice@example.org mail-user add-alias support@example.org alice@example.org mail-user add-user bob@example.org
Алиасы не получают собственного пароля и не используются как независимые IMAP-логины. При стандартном отключённом auth cache смена пароля действует для новых входов без рестарта Dovecot. Уже открытая IMAP-сессия при этом не закрывается.
8. DKIM и DNS до запуска
Для каждого домена создать отдельный RSA-2048 ключ с селектором mail. Команда отказывается перезаписывать существующий ключ. Закрытые ключи не копируются в wiki и DNS.
umask 077
for domain in example.org example.net; do
key=/usr/local/etc/exim/dkim/$domain.key
test ! -e "$key" || { echo "Key already exists: $domain" >&2; exit 1; }
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$key"
chown root:mail "$key"
chmod 0640 "$key"
echo "DNS owner: mail._domainkey.$domain"
openssl pkey -in "$key" -pubout -outform DER | openssl base64 -A
printf '\n'
done
Опубликовать по одному TXT mail._domainkey в каждой зоне со значением v=DKIM1; k=rsa; p= и соответствующим полученным base64 без пробелов и переносов. В интерфейсе DNS вводится одно TXT-значение; в BIND длинная строка делится на строки не более 255 байт внутри одной записи. Селектор Exim уже настроен на mail.
Начальные примеры для новой инфраструктуры:
mail.example.org. 300 IN A 192.0.2.10 example.org. 300 IN TXT "v=spf1 ip4:192.0.2.10 ~all" example.net. 300 IN TXT "v=spf1 ip4:192.0.2.10 ~all" _dmarc.example.org. 300 IN TXT "v=DMARC1; p=none" _dmarc.example.net. 300 IN TXT "v=DMARC1; p=none"
Если SPF уже существует, объединить разрешённые источники в одной записи; второй SPF не добавлять. DMARC ужесточают после проверки всех легитимных источников. Для уже работающего домена не заменять его текущую политику этими начальными примерами.
MX публикуется в финале после проверки службы:
example.org. 300 IN MX 10 mail.example.org. example.net. 300 IN MX 10 mail.example.org.
9. Проверки до сертификатов
/usr/local/sbin/exim -bV /usr/local/sbin/exim -bP primary_hostname spool_directory exim_user exim_group /usr/local/bin/doveconf -o ssl=no -o ssl_server_cert_file= -o ssl_server_key_file= -n /usr/local/sbin/exim -bt alice@example.org /usr/local/sbin/exim -bt admin@example.net /usr/local/sbin/exim -bt does-not-exist@example.org mount -p | awk '$2 == "/opt"'
Последняя маршрутизация должна завершиться отказом для неизвестного пользователя. ssl=no выше действует только на процесс проверки doveconf и не изменяет рабочий конфиг. Пустые пути сертификатов используются лишь для этой проверки до выпуска. Почтовые службы всё ещё выключены.
10. Последний этап базовой установки: сертификат, TLS и запуск
Сначала убедиться, что A mail.example.org существует в публичном DNS. Ручной DNS-01 не требует входящего TCP 80. Если домен имеет CAA, он должен разрешать выбранный центр сертификации. Самоподписанный сертификат для обычных почтовых клиентов здесь не используется.
10.1. Hook проверки и активации
Сохранить следующий скрипт как /usr/local/sbin/mail-activate-tls, root:wheel, 0750. Он проверяет UFS, права, цепочку доверия, имя, срок и соответствие ключа; лишь затем включает и запускает/перечитывает Dovecot и Exim. Если включен Nginx, проверяется и перечитывается он. На первом запуске до появления сертификата hook должен завершаться отказом.
#!/bin/sh
# Activate or renew trusted TLS for mail.example.org.
# Suitable as an ACME reload hook. It never creates certificates or keys.
# All checks finish before boot settings or running daemons are changed.
set -eu
PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin
export PATH
LC_ALL=C
export LC_ALL
umask 077
HOST=mail.example.org
CERTDIR=/usr/local/etc/mail-certs
CERT=$CERTDIR/fullchain.pem
KEY=$CERTDIR/privkey.pem
CAFILE=/etc/ssl/cert.pem
EXIM=/usr/local/sbin/exim
DOVECONF=/usr/local/bin/doveconf
OPENSSL=/usr/bin/openssl
SERVICE=/usr/sbin/service
die() { printf 'mail-activate-tls: %s\n' "$*" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || die "Run with sudo or as the root ACME hook."
case "${1-}" in
--locked) [ "$#" -eq 1 ] || die "No arguments are supported."; shift ;;
'') exec /usr/bin/lockf -t 0 /var/run/mail-activate-tls.lock "$0" --locked ;;
*) die "No arguments are supported." ;;
esac
# Fail closed if the mail filesystem is unavailable or read-only.
/sbin/mount -p | awk '
$2 == "/opt" && $3 == "ufs" && $4 !~ /(^|,)ro(,|$)/ { found = 1 }
END { exit !found }
' || die "/opt is not mounted as a writable UFS filesystem."
[ -d "$CERTDIR" ] || die "Certificate directory is missing."
[ -f "$CERT" ] && [ -r "$CERT" ] && [ -s "$CERT" ] ||
die "Trusted fullchain.pem is missing, empty or unreadable; services were not changed."
[ -f "$KEY" ] && [ -r "$KEY" ] && [ -s "$KEY" ] ||
die "privkey.pem is missing, empty or unreadable; services were not changed."
[ ! -L "$CERT" ] && [ ! -L "$KEY" ] ||
die "Install the certificate and key as real files in mail-certs, not symlinks into a private ACME directory."
[ -f "$CAFILE" ] && [ -r "$CAFILE" ] && [ -s "$CAFILE" ] ||
die "System CA bundle /etc/ssl/cert.pem is unavailable."
mail_gid=$(pw groupshow mail | awk -F: 'NR == 1 { print $3 }')
[ -n "$mail_gid" ] || die "Cannot determine the mail group."
# The fixed deployment directory is readable by the configured Exim group.
[ "$(stat -L -f '%u:%g:%Lp' "$KEY")" = "0:$mail_gid:640" ] ||
die "The private key must be owned by root:mail with mode 0640."
[ "$(stat -L -f '%u:%g:%Lp' "$CERTDIR")" = "0:$mail_gid:750" ] ||
die "The certificate directory must be root:mail with mode 0750."
[ "$(stat -L -f %u "$CERT")" -eq 0 ] ||
die "The certificate must belong to root."
case "$(stat -L -f %Lp "$CERT")" in
640|440)
[ "$(stat -L -f %g "$CERT")" = "$mail_gid" ] ||
die "A group-readable-only certificate must belong to the mail group."
;;
644|444) ;;
*) die "The certificate must be readable and not writable by group or others." ;;
esac
[ -x "$OPENSSL" ] && [ -x "$EXIM" ] && [ -x "$DOVECONF" ] ||
die "OpenSSL, Exim or doveconf is unavailable."
work=$(mktemp -d /var/run/mail-tls-check.XXXXXX)
cleanup() {
rm -f "$work/chain.p7b" "$work/cert.pub.pem" "$work/cert.pub.der" "$work/key.pub.der"
rmdir "$work" 2>/dev/null || true
}
trap cleanup EXIT
trap 'exit 1' HUP INT TERM
# Parse every certificate in the supplied PEM chain.
"$OPENSSL" crl2pkcs7 -nocrl -certfile "$CERT" -outform DER -out "$work/chain.p7b" >/dev/null 2>&1 ||
die "The supplied fullchain.pem is not a valid PEM certificate chain."
"$OPENSSL" x509 -in "$CERT" -noout -checkhost "$HOST" >/dev/null 2>&1 ||
die "The certificate does not match mail.example.org."
"$OPENSSL" x509 -in "$CERT" -noout -checkend 0 >/dev/null 2>&1 ||
die "The certificate is expired."
# Also checks notBefore, issuer chain, trust, server purpose and hostname.
# A self-signed or untrusted certificate never triggers service activation.
"$OPENSSL" verify -CAfile "$CAFILE" -purpose sslserver -verify_hostname "$HOST" -untrusted "$CERT" "$CERT" >/dev/null 2>&1 ||
die "Certificate verification failed: check system trust, full chain, dates and server name."
# Empty passphrase prevents an unattended hook from blocking on a prompt.
# Nothing from the private key is printed or written into temporary files.
"$OPENSSL" pkey -in "$KEY" -passin pass: -check -noout >/dev/null 2>&1 ||
die "The private key is invalid, encrypted or unreadable."
"$OPENSSL" x509 -in "$CERT" -pubkey -noout > "$work/cert.pub.pem" 2>/dev/null ||
die "Cannot read the certificate public key."
"$OPENSSL" pkey -pubin -in "$work/cert.pub.pem" -outform DER -out "$work/cert.pub.der" >/dev/null 2>&1 ||
die "Cannot normalize the certificate public key."
"$OPENSSL" pkey -in "$KEY" -passin pass: -pubout -outform DER -out "$work/key.pub.der" >/dev/null 2>&1 ||
die "Cannot derive the private key's public component."
cmp -s "$work/cert.pub.der" "$work/key.pub.der" ||
die "The certificate and private key do not match."
# Read the real installed production configurations with real certificates.
"$EXIM" -bV >/dev/null || die "Exim configuration check failed."
"$DOVECONF" -n >/dev/null || die "Dovecot configuration check failed."
# SnappyMail shares this certificate through nginx's root master process.
# Check nginx before reloading any certificate consumer when webmail is enabled.
reload_nginx=no
if [ -x /usr/local/sbin/nginx ] &&
[ "$(/usr/sbin/sysrc -n nginx_enable 2>/dev/null || true)" = YES ]; then
/usr/local/sbin/nginx -t || die "nginx configuration check failed."
reload_nginx=yes
fi
# No daemon, rc setting, certificate or key was changed before this point.
/usr/sbin/sysrc dovecot_enable=YES exim_enable=YES >/dev/null ||
die "Could not enable the two mail services at boot."
start_or_reload() {
srv=$1
if "$SERVICE" "$srv" onestatus >/dev/null 2>&1; then
"$SERVICE" "$srv" onereload ||
die "$srv reload failed; no forced restart was attempted."
else
"$SERVICE" "$srv" onestart ||
die "$srv start failed."
fi
}
# Dovecot owns both the SMTP AUTH and LMTP sockets, so it goes first.
start_or_reload dovecot
ready=no
for attempt in 1 2 3 4 5; do
if "$SERVICE" dovecot onestatus >/dev/null 2>&1 &&
[ -S /var/run/dovecot/auth-exim ] &&
[ -S /var/run/dovecot/lmtp-exim ]; then
ready=yes
break
fi
sleep 1
done
[ "$ready" = yes ] || die "Dovecot did not become ready with both Exim sockets; Exim was not changed."
start_or_reload exim
ready=no
for attempt in 1 2 3 4 5; do
if "$SERVICE" exim onestatus >/dev/null 2>&1; then
ready=yes
break
fi
sleep 1
done
[ "$ready" = yes ] || die "Exim did not become ready."
if [ "$reload_nginx" = yes ]; then
start_or_reload nginx
fi
printf 'Trusted TLS activated or reloaded for %s; Dovecot and Exim are running.\n' "$HOST"
if [ "$reload_nginx" = yes ]; then
printf 'SnappyMail nginx also reloaded with the same certificate.\n'
fi
chmod 0750 /usr/local/sbin/mail-activate-tls chown root:wheel /usr/local/sbin/mail-activate-tls
10.2. Ручной выпуск DNS-01
Выбрать свой реальный административный email. ACME хранится в отдельном root-only каталоге. Команды ниже вводятся на сервере от root; выданный TXT берется из фактического ответа acme.sh, а не из чужой инструкции.
install -d -o root -g wheel -m 0700 /usr/local/etc/virtual-mail/acme acme.sh --home /usr/local/etc/virtual-mail/acme --config-home /usr/local/etc/virtual-mail/acme --cert-home /usr/local/etc/virtual-mail/acme/certs --register-account --server letsencrypt -m postmaster@example.org acme.sh --home /usr/local/etc/virtual-mail/acme --config-home /usr/local/etc/virtual-mail/acme --cert-home /usr/local/etc/virtual-mail/acme/certs --issue --server letsencrypt --dns -d mail.example.org --keylength ec-256 --yes-I-know-dns-manual-mode-enough-go-ahead-please
Создать TXT с именем _acme-challenge.mail.example.org и значением, показанным acme.sh. Дождаться ответа авторитетных DNS, затем подтвердить проверку:
acme.sh --home /usr/local/etc/virtual-mail/acme --config-home /usr/local/etc/virtual-mail/acme --cert-home /usr/local/etc/virtual-mail/acme/certs --renew -d mail.example.org --ecc --yes-I-know-dns-manual-mode-enough-go-ahead-please
После успешного выпуска установить копии сертификата в рабочий каталог. Hook выставляет права до строгой проверки:
acme.sh --home /usr/local/etc/virtual-mail/acme --config-home /usr/local/etc/virtual-mail/acme --cert-home /usr/local/etc/virtual-mail/acme/certs --install-cert -d mail.example.org --ecc --fullchain-file /usr/local/etc/mail-certs/fullchain.pem --key-file /usr/local/etc/mail-certs/privkey.pem --reloadcmd 'chown root:mail /usr/local/etc/mail-certs/fullchain.pem /usr/local/etc/mail-certs/privkey.pem && chmod 0640 /usr/local/etc/mail-certs/fullchain.pem /usr/local/etc/mail-certs/privkey.pem && /usr/local/sbin/mail-activate-tls'
Ручной DNS-01 не обеспечивает автоматическое продление. До истечения срока повторить процедуру, опубликовав новое значение TXT; после успешного продления снова выполнить install-cert. Для автоматизации впоследствии перейти на DNS API с минимально необходимыми правами. Один сертификат mail.example.org используется для всех ящиков; почтовые клиенты всех доменов подключаются именно к этому имени.
10.3. Проверка клиента и переключение MX
service dovecot status service exim status sockstat -46l openssl s_client -connect mail.example.org:993 -servername mail.example.org -verify_hostname mail.example.org -verify_return_error -CAfile /etc/ssl/cert.pem openssl s_client -connect mail.example.org:465 -servername mail.example.org -verify_hostname mail.example.org -verify_return_error -CAfile /etc/ssl/cert.pem openssl s_client -starttls smtp -connect mail.example.org:587 -servername mail.example.org -verify_hostname mail.example.org -verify_return_error -CAfile /etc/ssl/cert.pem exim -bpc
После просмотра TLS нажать Ctrl-D. Проверить реальный вход alice@example.org и alice@example.net: IMAP mail.example.org:993 SSL/TLS; SMTP mail.example.org:465 SSL/TLS либо 587 STARTTLS, обязательный AUTH и полный логин. Неверный пароль и relay без AUTH должны отклоняться. Проверить доставку на канонический адрес и алиас, отсутствие неизвестных получателей; затем опубликовать MX из раздела DNS. Проверка из LAN через публичный IP может использовать NAT reflection: внешнюю доступность отдельно проверяют из независимой сети.
Для локальной системной почты после проверки настроить /etc/mail/mailer.conf:
sendmail /usr/local/sbin/exim send-mail /usr/local/sbin/exim mailq /usr/local/sbin/exim newaliases /usr/local/sbin/exim hoststat /usr/local/sbin/exim purgestat /usr/local/sbin/exim
Локальный sendmail-совместимый вызов и SMTP AUTH — разные пути. Приведенные проверки отправителя в ACL относятся к SMTP.
11. Необязательно: SnappyMail на этом же сервере
Этот этап выполняется после запуска почты с доверенным TLS. Nginx обслуживает веб-клиент, отдельный PHP-FPM pool работает от snappymail. Пароли почты и Maildir веб-процессу не передаются как файлы: он использует IMAPS/SMTP AUTH вошедшего пользователя.
11.1. Пакеты и каталоги
pkg install -n snappymail-php84 nginx php84-opcache pkg install -y snappymail-php84 nginx php84-opcache sysrc nginx_enable=NO php_fpm_enable=NO pw groupadd snappymail pw useradd snappymail -g snappymail -d /nonexistent -s /usr/sbin/nologin -w no install -d -o root -g wheel -m 0755 /opt/webmail /opt/webmail/nginx /opt/webmail/snappymail install -d -o www -g www -m 0700 /opt/webmail/nginx/client_body /opt/webmail/nginx/fastcgi install -d -o root -g wheel -m 0750 /opt/webmail/nginx/log install -d -o snappymail -g snappymail -m 0700 /opt/webmail/snappymail/data /opt/webmail/snappymail/tmp /opt/webmail/snappymail/sessions install -d -o root -g snappymail -m 0750 /opt/webmail/snappymail/log install -o snappymail -g snappymail -m 0600 /dev/null /opt/webmail/snappymail/log/php-error.log
Команды создания пользователя и пустого журнала относятся к первой установке. При повторном выполнении проверять существующие записи и не обнулять журналы.
Сохранить /usr/local/www/snappymail/include.php как root:wheel 0644:
<?php
define('APP_DATA_FOLDER_PATH', '/opt/webmail/snappymail/data/');
Код /usr/local/www/snappymail остается root-owned. Не делать его доступным на запись PHP-пользователю.
В /etc/hosts добавить единственное разрешение имени mail.example.org в 127.0.0.1, предварительно исправив конфликтующие записи этого имени:
127.0.0.1 mail.example.org
Это сохраняет локальный маршрут и проверку TLS по FQDN.
11.2. PHP-FPM и PHP
Установить php.ini-production как /usr/local/etc/php.ini, если своего файла ещё нет. Сохранить /usr/local/etc/php-fpm.conf (все конфиги PHP/Nginx ниже — root:wheel 0644):
[global] pid = /var/run/php-fpm.pid error_log = /opt/webmail/snappymail/log/php-fpm-master.log log_level = notice include = /usr/local/etc/php-fpm.d/snappymail.conf
Полный pool /usr/local/etc/php-fpm.d/snappymail.conf:
[snappymail] user = snappymail group = snappymail listen = /var/run/snappymail-php-fpm.sock listen.owner = www listen.group = www listen.mode = 0660 pm = ondemand pm.max_children = 4 pm.process_idle_timeout = 30s pm.max_requests = 500 chdir = /usr/local/www/snappymail clear_env = yes catch_workers_output = yes security.limit_extensions = .php env[TMPDIR] = /opt/webmail/snappymail/tmp php_admin_value[upload_tmp_dir] = /opt/webmail/snappymail/tmp php_admin_value[sys_temp_dir] = /opt/webmail/snappymail/tmp php_admin_value[session.save_path] = /opt/webmail/snappymail/sessions php_admin_value[error_log] = /opt/webmail/snappymail/log/php-error.log php_admin_flag[log_errors] = on php_admin_flag[display_errors] = off
Сохранить /usr/local/etc/php/99-snappymail.ini:
date.timezone = Europe/Moscow expose_php = Off display_errors = Off log_errors = On memory_limit = 128M max_execution_time = 90 post_max_size = 32M upload_max_filesize = 25M session.cookie_secure = 1 session.cookie_httponly = 1 session.cookie_samesite = Lax cgi.fix_pathinfo = 0 openssl.cafile = /etc/ssl/cert.pem curl.cainfo = /etc/ssl/cert.pem opcache.enable = 1 opcache.memory_consumption = 64 opcache.interned_strings_buffer = 8 opcache.max_accelerated_files = 20000 opcache.validate_timestamps = 1 opcache.revalidate_freq = 60
В /etc/rc.conf.d/php_fpm:
required_dirs="/opt/webmail/snappymail/data /opt/webmail/snappymail/tmp /opt/webmail/snappymail/sessions"
11.3. Инициализация приложения без публикации пароля
Сохранить следующий одноразовый PHP-скрипт в /root/bootstrap-webmail.php (root:wheel, 0600). Он предназначен для новой установки и отказывается заменять существующую конфигурацию. Запустить интерактивно с отключенным эхом терминала; задать новый длинный уникальный пароль администратора дважды. Сам пароль в файл или аргументы не записывается.
<?php
declare(strict_types=1);
umask(0077);
$base = '/opt/webmail/snappymail/data/_data_/_default_';
if (is_file($base.'/configs/application.ini')) {
throw new RuntimeException('Already configured; do not reset it.');
}
fwrite(STDERR, "New SnappyMail administrator password: ");
$first = rtrim((string)fgets(STDIN), "\r\n");
fwrite(STDERR, "\nRepeat password: ");
$second = rtrim((string)fgets(STDIN), "\r\n");
fwrite(STDERR, "\n");
if (strlen($first) < 20 || !hash_equals($first, $second)) {
throw new RuntimeException('Passwords differ or are too short.');
}
$_ENV['SNAPPYMAIL_INCLUDE_AS_API'] = 1;
require '/usr/local/www/snappymail/index.php';
$config = \RainLoop\Api::Config();
$config->Set('webmail', 'title', 'Webmail');
$config->Set('webmail', 'language', 'ru');
$config->Set('webmail', 'attachment_size_limit', 25);
$config->Set('security', 'allow_admin_panel', true);
$config->Set('security', 'admin_login', 'admin');
$config->Set('security', 'force_https', true);
$config->Set('admin_panel', 'allow_update', false);
$config->Set('ssl', 'verify_certificate', true);
$config->Set('ssl', 'allow_self_signed', false);
$config->Set('ssl', 'cafile', '/etc/ssl/cert.pem');
$config->Set('contacts', 'enable', true);
$config->Set('contacts', 'type', 'sqlite');
$config->Set('contacts', 'sqlite_global', false);
$config->SetPassword(new \SnappyMail\SensitiveString($first));
if (!$config->Save()) { throw new RuntimeException('Save failed.'); }
unset($first, $second);
$ssl = ['verify_peer'=>true, 'verify_peer_name'=>true,
'allow_self_signed'=>false, 'cafile'=>'/etc/ssl/cert.pem'];
$settings = [
'IMAP' => ['host'=>'mail.example.org','port'=>993,'type'=>1,
'shortLogin'=>false,'lowerLogin'=>true,'ssl'=>$ssl],
'SMTP' => ['host'=>'mail.example.org','port'=>465,'type'=>1,
'shortLogin'=>false,'lowerLogin'=>true,'useAuth'=>true,
'setSender'=>false,'usePhpMail'=>false,'authPlainLine'=>false,'ssl'=>$ssl],
'Sieve' => ['host'=>'mail.example.org','port'=>4190,'type'=>1,
'enabled'=>false,'shortLogin'=>false],
'whiteList' => ''
];
$dir = $base.'/domains';
if (!is_dir($dir)) { mkdir($dir, 0700, true); }
foreach (['default.json','gmail.com.json','hotmail.com.json','mail.example.org.json','disabled'] as $sample) {
if (is_file($dir.'/'.$sample)) { unlink($dir.'/'.$sample); }
}
foreach (['example.org','example.net'] as $domain) {
\RainLoop\Model\Domain::fromArray($domain, $settings);
file_put_contents($dir.'/'.$domain.'.json',
json_encode($settings, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES|JSON_THROW_ON_ERROR)."\n");
chmod($dir.'/'.$domain.'.json', 0600);
}
if (is_file($base.'/admin_password.txt')) { unlink($base.'/admin_password.txt'); }
echo "Configured.\n";
Выполнить в интерактивном /bin/sh:
php -l /root/bootstrap-webmail.php
stty -echo
trap 'stty echo' EXIT HUP INT TERM
php /root/bootstrap-webmail.php
result=$?
stty echo
trap - EXIT HUP INT TERM
test "$result" -eq 0 || exit "$result"
chown -R snappymail:snappymail /opt/webmail/snappymail/data
find /opt/webmail/snappymail/data -type d -exec chmod 0700 {} +
find /opt/webmail/snappymail/data -type f -exec chmod 0600 {} +
rm /root/bootstrap-webmail.php
При ошибке не запускать веб-сервер до исправления и проверки настроек администратора. Панель после запуска — https://mail.example.org/?admin, логин admin, заданный вами пароль. В FreeBSD регистр имени API важен: используется RainLoop\Api, не RainLoop\API.
11.4. Nginx и запуск
Полный минимальный /usr/local/etc/nginx/nginx.conf для сервера, на котором еще нет других сайтов:
user www;
worker_processes auto;
pid /var/run/nginx.pid;
error_log /opt/webmail/nginx/log/error.log warn;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
server_tokens off;
access_log /opt/webmail/nginx/log/access.log;
sendfile on;
tcp_nopush on;
keepalive_timeout 65;
client_body_temp_path /opt/webmail/nginx/client_body;
fastcgi_temp_path /opt/webmail/nginx/fastcgi;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
server {
listen 127.0.0.1:80;
listen 192.168.1.12:80;
server_name mail.example.org;
return 308 https://mail.example.org$request_uri;
}
server {
listen 127.0.0.1:443 ssl;
listen 192.168.1.12:443 ssl;
server_name mail.example.org;
ssl_certificate /usr/local/etc/mail-certs/fullchain.pem;
ssl_certificate_key /usr/local/etc/mail-certs/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:4m;
ssl_session_timeout 1h;
ssl_session_tickets off;
root /usr/local/www/snappymail;
index index.php;
client_max_body_size 32m;
add_header Strict-Transport-Security "max-age=15552000" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
add_header Referrer-Policy no-referrer always;
location = /data { return 404; }
location ^~ /data/ { return 404; }
location = /include.php { return 404; }
location = /_include.php { return 404; }
location ~ (^|/)\. { return 404; }
location ~ ^/snappymail/v/[^/]+/(app|vendor)/ { return 404; }
location = /webmail { return 302 /; }
location = /webmail/ { return 302 /; }
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /index.php {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
fastcgi_param HTTPS $https if_not_empty;
fastcgi_param HTTP_PROXY "";
fastcgi_pass unix:/var/run/snappymail-php-fpm.sock;
fastcgi_hide_header X-Powered-By;
fastcgi_read_timeout 90s;
fastcgi_buffer_size 32k;
fastcgi_buffers 16 16k;
}
location ~* \.php(?:/|$) { return 404; }
}
}
Если сайты уже есть, добавить server-блоки в существующий http, согласовав глобальные параметры. В /etc/rc.conf.d/nginx:
required_dirs="/opt/webmail/nginx/client_body /opt/webmail/nginx/log"
Проверить и включить службы:
php-fpm -t nginx -t sysrc php_fpm_enable=YES nginx_enable=YES service php_fpm start service nginx start fetch -o /dev/null https://mail.example.org/
Проверить в браузере вход полным адресом, список папок, отправку самому себе и наличие письма в INBOX/Sent. Файлы данных, include.php и произвольные .php должны возвращать 404. При добавлении почтового домена через mail-user также добавить его в SnappyMail через админ-панель с теми же параметрами IMAP/SMTP и полной формой логина.
11.5. Если публичный 443 занимает другой Nginx
На внешнем Nginx добавить следующие server-блоки внутри http. Использовать доверенный сертификат mail.example.org; приватный ключ на frontend принадлежит root, режим 0600. Сертификат нужно обновлять на обоих серверах при продлении. HTTPS от frontend до backend тоже проверяется:
# Add these server blocks inside the FRONT nginx http {} context.
# Copy the existing mail.example.org certificate from the MAIL server:
# /usr/local/etc/mail-certs/fullchain.pem -> /usr/local/etc/nginx/ssl/mail.example.org/fullchain.pem
# /usr/local/etc/mail-certs/privkey.pem -> /usr/local/etc/nginx/ssl/mail.example.org/privkey.pem
# Keep the private key root-owned, mode 0600, on the front server.
# The front nginx must be able to reach 192.168.1.12 TCP 443.
server {
listen 80;
server_name mail.example.org;
return 308 https://mail.example.org$request_uri;
}
server {
listen 443 ssl;
server_name mail.example.org;
ssl_certificate /usr/local/etc/nginx/ssl/mail.example.org/fullchain.pem;
ssl_certificate_key /usr/local/etc/nginx/ssl/mail.example.org/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 32m;
location / {
proxy_pass https://192.168.1.12;
proxy_http_version 1.1;
proxy_set_header Host mail.example.org;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Connection "";
proxy_ssl_server_name on;
proxy_ssl_name mail.example.org;
proxy_ssl_verify on;
# FreeBSD system CA bundle; on Debian/Ubuntu use
# /etc/ssl/certs/ca-certificates.crt if /etc/ssl/cert.pem is absent.
proxy_ssl_trusted_certificate /etc/ssl/cert.pem;
proxy_ssl_verify_depth 3;
proxy_connect_timeout 10s;
proxy_read_timeout 90s;
proxy_send_timeout 90s;
proxy_buffer_size 32k;
proxy_buffers 16 16k;
}
}
Перед reload выполнить nginx -t. В этой схеме NAT 443 указывает на frontend, а он должен достигать 192.168.1.12:443 по LAN. Порты SMTP/IMAP идут непосредственно на почтовый сервер.
12. Необязательно: ClamAV и проверка SMTP-писем
Антивирус подключается после проверки базовой доставки. Exim передает содержимое clamd потоком по loopback; сканеру не нужен доступ к Maildir, очереди и закрытым ключам.
pkg install -n clamav pkg install -y clamav sysrc clamav_clamd_enable=NO clamav_freshclam_enable=NO # Пакет может включить clamav в дополнительную группу mail. # Для потоковой проверки это не нужно: case " $(id -Gn clamav) " in *' mail '*) pw groupmod mail -d clamav ;; esac install -d -o root -g clamav -m 0750 /opt/clamav /opt/clamav/log install -d -o clamav -g clamav -m 0750 /opt/clamav/db /var/run/clamav install -d -o clamav -g clamav -m 0700 /opt/clamav/tmp install -o clamav -g clamav -m 0640 /dev/null /opt/clamav/log/clamd.log install -o clamav -g clamav -m 0640 /dev/null /opt/clamav/log/freshclam.log
Штатные rc-скрипты FreeBSD проверяют базы в /var/db/clamav. Для новой установки пустой каталог можно заменить ссылкой; существующие базы не удалять:
if [ -L /var/db/clamav ]; then
test "$(readlink /var/db/clamav)" = /opt/clamav/db || exit 1
elif [ -d /var/db/clamav ]; then
rmdir /var/db/clamav || exit 1
ln -s /opt/clamav/db /var/db/clamav
elif [ ! -e /var/db/clamav ]; then
ln -s /opt/clamav/db /var/db/clamav
else
echo 'Unexpected database path' >&2
exit 1
fi
chown -h root:wheel /var/db/clamav
/usr/local/etc/clamd.conf:
LogFile /opt/clamav/log/clamd.log LogFileMaxSize 0 LogTime yes LogRotate no LogSyslog yes LogFacility LOG_DAEMON PidFile /var/run/clamav/clamd.pid DatabaseDirectory /opt/clamav/db TemporaryDirectory /opt/clamav/tmp User clamav TCPSocket 3310 TCPAddr 127.0.0.1 EnableShutdownCommand no MaxThreads 4 MaxConnectionQueueLength 30 ReadTimeout 120 CommandReadTimeout 30 StreamMaxLength 64M MaxFileSize 64M MaxScanSize 256M MaxScanTime 45000 MaxRecursion 17 MaxFiles 10000 AlertExceedsMax yes ScanMail yes ScanArchive yes SelfCheck 3600 ConcurrentDatabaseReload yes
/usr/local/etc/freshclam.conf:
DatabaseDirectory /opt/clamav/db DatabaseOwner clamav UpdateLogFile /opt/clamav/log/freshclam.log LogFileMaxSize 0 LogTime yes LogRotate no LogSyslog yes LogFacility LOG_DAEMON PidFile /var/run/clamav/freshclam.pid DatabaseMirror database.clamav.net ScriptedUpdates yes Checks 12 ConnectTimeout 30 ReceiveTimeout 90 TestDatabases yes NotifyClamd /usr/local/etc/clamd.conf
В /etc/rc.conf.d/clamav_clamd и /etc/rc.conf.d/clamav_freshclam добавить по строке:
required_dirs="/opt/clamav/db /opt/clamav/tmp /opt/clamav/log"
Первую загрузку баз выполнить до запуска clamd:
freshclam --config-file=/usr/local/etc/freshclam.conf sigtool --info /opt/clamav/db/main.cvd sigtool --info /opt/clamav/db/daily.cvd sigtool --info /opt/clamav/db/bytecode.cvd sysrc clamav_clamd_enable=YES clamav_freshclam_enable=YES service clamav_clamd start service clamav_freshclam start service clamav_clamd status sockstat -4l | grep 3310
При первом bootstrap сообщение NotifyClamd о еще не запущенном clamd допустимо только после успешной загрузки и проверки баз. До подключения Exim clamd должен работать. Checks 12 означает проверку обновлений 12 раз в сутки. Если официальный CDN отвечает 403/429, не повторять запросы в цикле: разобраться с ограничением или использовать подходящее HTTPS-зеркало официальных подписанных баз. Например DatabaseMirror https://mirror.truenetwork.ru/clamav и ScriptedUpdates no загружают полные CVD; сохраняются DNS-проверка актуальности и проверка подписи. Доступность зеркала и свежесть баз проверяются в момент установки.
В главной части конфигурации Exim, до begin acl, добавить:
av_scanner = clamd:127.0.0.1 3310
В acl_check_data после существующих проверок From и перед завершающим accept добавить:
deny
malware = * / tmo=60s
message = Message rejected by antivirus policy ($malware_name)
log_message = ClamAV detected $malware_name
Затем:
exim -bV service exim reload exim -bP av_scanner tail -20 /opt/clamav/log/freshclam.log
Обнаруженное содержимое отклоняется после DATA с 5xx, включая AUTH-порты 465/587 и SnappyMail. Без defer_ok сбой сканера дает временный 451. AlertExceedsMax yes делает превышение лимитов видимым как Heuristics.Limits.Exceeded. Существующая локальная отправка через sendmail-совместимый вызов не проходит SMTP DATA ACL; ее отдельно учитывают при расширении политики.
Проверить контролируемым письмом обычную доставку, затем стандартным тестом EICAR и ZIP с ним: вредоносное тестовое вложение не должно попасть в ящик. Использовать отдельный тестовый ящик и удалить только его тестовые данные. Сам EICAR брать с официального сайта; не включать его тело в общие журналы и документацию.
12.1. Ротация журналов
Установить newsyslog-правила, чтобы журналы не заполнили UFS. Для Exim пример /usr/local/etc/newsyslog.conf.d/virtual-mail.conf:
/var/log/exim/mainlog mailnull:mail 0640 7 1024 * JC /var/log/exim/rejectlog mailnull:mail 0640 7 1024 * JC /var/log/exim/paniclog mailnull:mail 0640 7 1024 * JC /opt/clamav/log/clamd.log clamav:clamav 0640 7 1024 * JC /var/run/clamav/clamd.pid 1 /opt/clamav/log/freshclam.log clamav:clamav 0640 7 1024 * JC /var/run/clamav/freshclam.pid 1
Проверить newsyslog -n -f /usr/local/etc/newsyslog.conf.d/virtual-mail.conf. Правила Exim относятся и к базовой установке без ClamAV; строки ClamAV добавлять только после его установки. Веб-журналы также включить в newsyslog: nginx требует сигнал USR1 для открытия новых файлов, PHP-FPM — штатный сигнал переоткрытия своих журналов. После пакетного обновления проверять владельцев, режимы и членство clamav в группах.
13. Необязательно: Grafana Alloy
Alloy устанавливается отдельно при наличии центральных Loki/Prometheus и их CA. Не выставлять его API на публичный интерфейс. Стандартный набор: /var/log/auth.log, /var/log/messages, CPU/RAM, версии kernel/userland. Состояние и WAL разместить в /opt/alloy, конфигурацию — в /usr/local/etc/alloy.flow, запуск от отдельного пользователя alloy.
pkg install alloy pw groupshow alloy >/dev/null 2>&1 || pw groupadd alloy pw usershow alloy >/dev/null 2>&1 || pw useradd alloy -g alloy -d /opt/alloy -s /usr/sbin/nologin -w no install -d -o alloy -g alloy -m 0750 /opt/alloy sysrc alloy_enable=NO alloy_user=alloy alloy_group=alloy sysrc alloy_storage_path=/opt/alloy alloy_listen_address=127.0.0.1:9200
Полная интеграция зависит от адресов приёмников и способа аутентификации организации. В конфигурации использовать password_file с отдельным root:alloy 0640 файлом, проверку CA и уникальную метку host. Для чтения auth/messages предоставить группе alloy доступ 0640 и сохранить те же владельца/группу в newsyslog; членство в широких системных группах не требуется. Проверить alloy validate, доставку контрольных событий, появление CPU/RAM и версий в центральной системе; только затем включить alloy_enable=YES. Этот раздел — параметры подключения, а не готовая конфигурация неизвестного центрального сервера.
14. Обслуживание и диагностика
service exim status service dovecot status exim -bpc exim -bp tail -50 /var/log/exim/mainlog tail -50 /var/log/exim/rejectlog tail -50 /var/log/maillog doveadm user alice@example.org df -hT / /opt openssl x509 -in /usr/local/etc/mail-certs/fullchain.pem -noout -dates -subject
Не публиковать users, приватные DKIM-ключи, TLS-ключ, секреты Alloy и содержимое пользовательских писем. Следить за пространством отдельно на / и /opt. Запланировать продление сертификата до срока истечения; после обновления проверить все его потребители, включая внешний HTTPS frontend. Перед заменой конфигураций сравнивать действующее состояние. Rspamd с обучением по Junk подключается отдельной задачей после устойчивой базовой доставки; его установка не является условием работы этой схемы.
Первичные источники
- FreeBSD Handbook: диски и UFS
- gpart(8)
- Exim: Dovecot authenticator
- Exim: антивирус в ACL
- Dovecot 2.4: Exim SASL
- Dovecot 2.4: passwd-file
- Dovecot: LMTP
- acme.sh: ручной DNS-01
- acme.sh: install-cert и hook
- SnappyMail: администрирование
- Nginx: reverse proxy и проверка upstream TLS
- ClamAV: конфигурация
- FreshClam: ошибки и ограничения CDN
- ClamAV: зеркала баз
- EICAR: стандартный антивирусный тест
- Grafana Alloy: документация