Почтовый сервер на FreeBSD 15.1: Exim и Dovecot

Материал из Bas Wiki
Версия от 01:29, 29 октября 2026; Bas (обсуждение | вклад) (Пошаговая установка Exim и Dovecot 2.4 на FreeBSD 15.1: UFS /opt, домены, алиасы, TLS, DKIM, SnappyMail, ClamAV и Alloy)
(разн.) ← Предыдущая версия | Текущая версия (разн.) | Следующая версия → (разн.)
Перейти к навигации Перейти к поиску

Почтовый сервер на 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 подключается отдельной задачей после устойчивой базовой доставки; его установка не является условием работы этой схемы.

Первичные источники