diff --git a/README.md b/README.md index 70c6112..3436c47 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ ``` - **Приём (SMTP).** Команды разбираются по строкам (`LineBasedFrameDecoder`): работает и пачка команд в одном пакете, и строка, разорванная на два пакета. Письмо сохраняется или ставится в очередь **до** ответа `250`; если сохранить не удалось — `451`, и отправитель повторит. Работа с базой и DNS идёт не в сетевом потоке. -- **TLS.** `STARTTLS` на порту SMTP (RFC 3207) и, если задан `SMTPS_PORT`, неявный TLS (RFC 8314, обычно 465); TLS 1.2/1.3. После `STARTTLS` сессия начинается заново (новый `EHLO`), а команды, присланные открытым текстом вместе со `STARTTLS`, не выполняются — соединение закрывается (CVE-2011-0411). `AUTH` предлагается только внутри TLS (или без него при `SMTP_AUTH_WITHOUT_TLS=true`). Сертификат — свой (`MAIL_TLS_CERT` + `MAIL_TLS_KEY`, например Let's Encrypt) или самоподписанный: создаётся один раз в `MAIL_TLS_DIR` и потом переиспользуется. +- **TLS.** `STARTTLS` на порту SMTP (RFC 3207) и, если задан `SMTPS_PORT`, неявный TLS (RFC 8314, обычно 465); TLS 1.2/1.3. После `STARTTLS` сессия начинается заново (новый `EHLO`), а команды, присланные открытым текстом вместе со `STARTTLS`, не выполняются — соединение закрывается (CVE-2011-0411). `AUTH` предлагается только внутри TLS (или без него при `SMTP_AUTH_WITHOUT_TLS=true`). Сертификат — свой (`MAIL_TLS_CERT` + `MAIL_TLS_KEY`, например Let's Encrypt) или самоподписанный: создаётся один раз в `MAIL_TLS_DIR` и потом переиспользуется. Свой сертификат сервер перечитывает сам, когда файлы меняются (проверка раз в минуту): обновлённый Let's Encrypt подхватывается без перезапуска, недописанный файл не ломает TLS — остаётся прежний сертификат. - **Не открытый релей.** - Письмо на свой домен принимается от кого угодно. - Письмо на чужой домен — только после `AUTH`, иначе `550 5.7.1 Relaying denied`. @@ -72,7 +72,7 @@ API: `GET /api/config` (без входа), `/api/me`, `/api/me/password`, `/api | `MAIL_CATCH_ALL` | ящик-ловушка: письма на адреса без своего ящика здесь (и на чужие домены) — сюда, наружу ничего; ящик создаётся при старте | — | | `SMTP_PORT` | порт SMTP | `3025` | | `MAIL_TLS` | TLS для SMTP: `STARTTLS` и порт `SMTPS_PORT` | `true` | -| `MAIL_TLS_CERT`, `MAIL_TLS_KEY` | свой сертификат: PEM-цепочка и ключ PKCS#8; без них — самоподписанный | — | +| `MAIL_TLS_CERT`, `MAIL_TLS_KEY` | свой сертификат: PEM-цепочка и ключ (PKCS#8, `RSA PRIVATE KEY` или `EC PRIVATE KEY`); перечитывается при изменении; без них — самоподписанный | — | | `MAIL_TLS_DIR` | где лежит самоподписанный сертификат | `./tls` (в образе `/app/data/tls`) | | `SMTPS_PORT` | порт неявного TLS (465); `0` — выключен | `0` | | `IMAP_ENABLED`, `IMAP_PORT`, `IMAPS_PORT` | IMAP: включён; порт со STARTTLS; порт неявного TLS (`0` — выключен) | `true`, `3143`, `0` | @@ -130,15 +130,43 @@ gitea.jambotron.com/jambotron_gang/reactive-mail-server:<первые 8 симв docker compose -f docker-compose.image.yml up -d ``` +### Настоящая почта в интернете (75.6) + +`docker-compose.public.yml` — приём писем для `MAIL_DOMAINS` и отправка наружу прямо на MX получателей (`OUTBOUND_MODE=mx`, порт 25), с подписью DKIM; регистрация закрыта, ловушки нет. Нужна своя ВМ с публичным адресом (Jambotron 60.8: «Публичный адрес», SMTP «да» — порт 25 по умолчанию закрыт). На этом адресе открыты: + +| Порт | Что | +|---|---| +| 25 | SMTP: письма от других серверов (STARTTLS) | +| 587 | submission для почтовых программ (STARTTLS + вход) — тот же SMTP, что 25 | +| 465 | SMTPS, неявный TLS | +| 993 | IMAPS, неявный TLS | +| 80 | только Let's Encrypt (HTTP-01), сервис `acme` | + +Сертификат для `MAIL_HOSTNAME` выпускает и продлевает сервис `acme` (lego, ключ EC256, продление за 30 дней, проверка дважды в сутки); хук кладёт пару в том `tls`, сервер перечитывает её сам. `mail` стартует после первого сертификата. Веб-почта (сервис `mail`, порт `8080`) — через Caddy Jambotron под **другим** именем, например `webmail.<домен>`: `MAIL_HOSTNAME` смотрит на публичный адрес ВМ, а не на Caddy. + +Переменные: `MAIL_DOMAINS` (например `jambocheck.com`), `MAIL_HOSTNAME` (`mail.jambocheck.com`), `ACME_EMAIL` (по умолчанию `postmaster@<домен>`). + +DNS до первого запуска: + +| Запись | Значение | +|---|---| +| `A mail.<домен>` | публичный адрес ВМ | +| PTR публичного адреса | `mail.<домен>` (панель хостера, Reverse DNS) | +| `MX <домен>` | `10 mail.<домен>` | +| `TXT <домен>` | `v=spf1 ip4:<публичный адрес> -all` | +| `TXT _dmarc.<домен>` | `v=DMARC1; p=none; rua=mailto:postmaster@<домен>` | +| `TXT mail._domainkey.<домен>` | из лога `mail` при первом запуске (ключ DKIM лежит в томе `data`) | + +Ящик `admin@<домен>` — пароль в логе `mail` при первом запуске и в `/app/data/initial-passwords.txt`. + ## Не сделано -- [ ] Сертификат Let's Encrypt для настоящей почты (75.6): сейчас — свой файл или самоподписанный. - [ ] IMAP: проверить с Thunderbird на настоящем адресе; вложенные папки, CONDSTORE/QRESYNC, поиск по `BODY`/`TEXT` без перебора писем. - [ ] Пересылка не берёт вложения исходного письма — их надо прикрепить заново. - [ ] Встроенные картинки HTML-писем (`cid:`) показываются как вложения, а не внутри письма. - [ ] Защита регистрации и входа от перебора (лимит попыток, капча). - [ ] Письмо нескольким получателям: при ошибке на втором первый может получить его дважды (после повтора отправителя). -- [ ] Своя настоящая почта: отдельная VPS, порт 25, PTR, отдельный домен (чек-лист Jambotron 75.6). +- [ ] Настоящая почта (75.6): `docker-compose.public.yml` готов, первый запуск — `mail.jambocheck.com` на публичном адресе Horizon; адрес «прогревать» несколько недель, письма пользователям Jambotron пока через Resend (75.5). ## Лицензия diff --git a/backend/src/main/java/com/mailserver/smtp/ReloadingKeyManager.java b/backend/src/main/java/com/mailserver/smtp/ReloadingKeyManager.java new file mode 100644 index 0000000..ddb182f --- /dev/null +++ b/backend/src/main/java/com/mailserver/smtp/ReloadingKeyManager.java @@ -0,0 +1,194 @@ +package com.mailserver.smtp; + +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.openssl.PEMKeyPair; +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.net.ssl.SSLEngine; +import javax.net.ssl.X509ExtendedKeyManager; +import java.io.InputStream; +import java.io.Reader; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.security.Principal; +import java.security.PrivateKey; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.time.Clock; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Server certificate and key from PEM files, read again when the files change — a Let's Encrypt certificate + * renewed on disk (lego, certbot) is picked up without a restart, for STARTTLS and implicit TLS alike: the JDK + * asks the key manager on every handshake. The files are checked at most once per {@code interval}; a file + * that cannot be read (a renewal half written) keeps the previous certificate and is tried again next time. + *

+ * Key: PKCS#8 ({@code PRIVATE KEY}) or the traditional {@code RSA PRIVATE KEY} / {@code EC PRIVATE KEY} that + * lego and certbot write. Each loaded pair gets its own alias, so a handshake that asks for the chain and then + * for the key never mixes two generations. + */ +public final class ReloadingKeyManager extends X509ExtendedKeyManager { + + private static final Logger log = LoggerFactory.getLogger(ReloadingKeyManager.class); + private static final String ALIAS_PREFIX = "mail-"; + + record Material(String alias, X509Certificate[] chain, PrivateKey key, FileTime certTime, FileTime keyTime) { + } + + private final Path certFile; + private final Path keyFile; + private final Duration interval; + private final Clock clock; + /** The current pair and the one before it (a handshake may still hold its alias) */ + private final Map byAlias = new ConcurrentHashMap<>(); + private volatile Material current; + private volatile long checkedAt; + private int generation; + + public ReloadingKeyManager(Path certFile, Path keyFile, Duration interval, Clock clock) throws Exception { + this.certFile = certFile; + this.keyFile = keyFile; + this.interval = interval; + this.clock = clock; + install(load()); + this.checkedAt = clock.millis(); + } + + /** The pair to use now; reads the files again when the interval has passed and they changed */ + Material material() { + long now = clock.millis(); + if (now - checkedAt >= interval.toMillis()) { + synchronized (this) { + if (now - checkedAt >= interval.toMillis()) { + checkedAt = now; + reloadIfChanged(); + } + } + } + return current; + } + + private void reloadIfChanged() { + Material old = current; + try { + if (Files.getLastModifiedTime(certFile).equals(old.certTime()) + && Files.getLastModifiedTime(keyFile).equals(old.keyTime())) { + return; + } + Material fresh = load(); + install(fresh); + log.info("TLS: certificate {} reloaded, valid until {}", certFile, fresh.chain()[0].getNotAfter()); + } catch (Exception e) { + log.warn("TLS: cannot read {} / {} ({}), keeping the certificate valid until {}", + certFile, keyFile, e.toString(), old.chain()[0].getNotAfter()); + } + } + + private synchronized void install(Material material) { + Material previous = current; + byAlias.keySet().removeIf(alias -> previous == null || !alias.equals(previous.alias())); + byAlias.put(material.alias(), material); + current = material; + } + + private Material load() throws Exception { + FileTime certTime = Files.getLastModifiedTime(certFile); + FileTime keyTime = Files.getLastModifiedTime(keyFile); + X509Certificate[] chain; + try (InputStream in = Files.newInputStream(certFile)) { + chain = CertificateFactory.getInstance("X.509").generateCertificates(in).stream() + .map(X509Certificate.class::cast).toArray(X509Certificate[]::new); + } + if (chain.length == 0) { + throw new IllegalStateException("no certificate in " + certFile); + } + PrivateKey key = readKey(keyFile); + if (!chain[0].getPublicKey().getAlgorithm().equals(key.getAlgorithm())) { + throw new IllegalStateException("the key in " + keyFile + " is " + key.getAlgorithm() + + ", the certificate is " + chain[0].getPublicKey().getAlgorithm()); + } + String alias; + synchronized (this) { + alias = ALIAS_PREFIX + (++generation); + } + return new Material(alias, chain, key, certTime, keyTime); + } + + static PrivateKey readKey(Path file) throws Exception { + try (Reader reader = Files.newBufferedReader(file, StandardCharsets.US_ASCII); + PEMParser parser = new PEMParser(reader)) { + Object object; + while ((object = parser.readObject()) != null) { + JcaPEMKeyConverter converter = new JcaPEMKeyConverter(); + if (object instanceof PrivateKeyInfo info) { + return converter.getPrivateKey(info); + } + if (object instanceof PEMKeyPair pair) { + return converter.getPrivateKey(pair.getPrivateKeyInfo()); // SEC1 may come without the public part + } + // EC PARAMETERS before EC PRIVATE KEY (openssl ecparam -genkey) — skip + } + } + throw new IllegalStateException("no private key in " + file); + } + + private String serverAlias(String keyType) { + Material material = material(); + String algorithm = material.key().getAlgorithm(); + boolean fits = keyType == null || keyType.equalsIgnoreCase(algorithm) + || ("RSA".equals(algorithm) && keyType.equalsIgnoreCase("RSASSA-PSS")) + || ("EC".equals(algorithm) && keyType.toUpperCase().startsWith("EC")); + return fits ? material.alias() : null; + } + + @Override + public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) { + return serverAlias(keyType); + } + + @Override + public String chooseEngineServerAlias(String keyType, Principal[] issuers, SSLEngine engine) { + return serverAlias(keyType); + } + + @Override + public String[] getServerAliases(String keyType, Principal[] issuers) { + String alias = serverAlias(keyType); + return alias == null ? null : new String[]{alias}; + } + + @Override + public X509Certificate[] getCertificateChain(String alias) { + Material material = alias == null ? null : byAlias.get(alias); + return material == null ? null : material.chain().clone(); + } + + @Override + public PrivateKey getPrivateKey(String alias) { + Material material = alias == null ? null : byAlias.get(alias); + return material == null ? null : material.key(); + } + + @Override + public String[] getClientAliases(String keyType, Principal[] issuers) { + return null; + } + + @Override + public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) { + return null; + } + + @Override + public String chooseEngineClientAlias(String[] keyType, Principal[] issuers, SSLEngine engine) { + return null; + } +} diff --git a/backend/src/main/java/com/mailserver/smtp/SmtpTls.java b/backend/src/main/java/com/mailserver/smtp/SmtpTls.java index 879eb42..a0492a5 100644 --- a/backend/src/main/java/com/mailserver/smtp/SmtpTls.java +++ b/backend/src/main/java/com/mailserver/smtp/SmtpTls.java @@ -22,6 +22,7 @@ import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.SecureRandom; import java.security.cert.X509Certificate; +import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.util.Base64; @@ -30,7 +31,8 @@ import java.util.Date; /** * Certificate for SMTP TLS (STARTTLS and implicit TLS): *