Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f3ad77b69 | ||
|
|
711807847e | ||
|
|
620aae179b | ||
|
|
a37acfaaa7 | ||
|
|
87c6e5325d | ||
|
|
152e93d997 | ||
|
|
eaffffd4cc |
@@ -0,0 +1,9 @@
|
||||
.git
|
||||
.idea
|
||||
.vscode
|
||||
**/node_modules
|
||||
**/build
|
||||
**/dist
|
||||
**/.gradle
|
||||
**/.angular
|
||||
*.log
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
# Docker image of reactive-mail-server — like htop-web: Drone builds backend/Dockerfile,
|
||||
# master pushes it to the Gitea container registry of jambotron.com.
|
||||
# pull request -> build only (image is not pushed)
|
||||
# push master -> gitea.jambotron.com/jambotron_gang/reactive-mail-server:latest and :<commit 8>
|
||||
# Drone secrets of this repo: REGISTRY_USERNAME, REGISTRY_PASSWORD (Gitea user with write:package in jambotron_gang)
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: build-test
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- pull_request
|
||||
|
||||
steps:
|
||||
- name: build-test
|
||||
image: plugins/docker
|
||||
settings:
|
||||
dockerfile: backend/Dockerfile
|
||||
context: .
|
||||
repo: gitea.jambotron.com/jambotron_gang/reactive-mail-server
|
||||
dry_run: true
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: build-and-push
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- push
|
||||
branch:
|
||||
- master
|
||||
|
||||
steps:
|
||||
- name: build-and-push
|
||||
image: plugins/docker
|
||||
settings:
|
||||
dockerfile: backend/Dockerfile
|
||||
context: .
|
||||
registry: gitea.jambotron.com
|
||||
repo: gitea.jambotron.com/jambotron_gang/reactive-mail-server
|
||||
tags:
|
||||
- latest
|
||||
- ${DRONE_COMMIT_SHA:0:8}
|
||||
username:
|
||||
from_secret: REGISTRY_USERNAME
|
||||
password:
|
||||
from_secret: REGISTRY_PASSWORD
|
||||
@@ -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` |
|
||||
@@ -100,7 +100,7 @@ cd frontend && npm install && npx ng serve # http://loca
|
||||
В мастере деплоя выбрать `docker-compose.jambotron.yml`, домен дать сервису `mail`, порт `8080`.
|
||||
|
||||
- Пароль базы и ключ токенов создаёт контейнер `secrets-init` при первом запуске (том `secrets`) — вводить ничего не нужно, в git ничего нет.
|
||||
- Ящики `admin@mail.test` (первый) и `trap@mail.test` (ловушка) создаются при старте, их пароли — в файле `/app/data/initial-passwords.txt` контейнера `mail` (том `data`, права 600): `docker exec <контейнер mail> cat /app/data/initial-passwords.txt`. Прочитать и сменить пароль в настройках.
|
||||
- Ящики `admin@mail.test` (первый) и `trap@mail.test` (ловушка) создаются при старте, их пароли печатаются в лог сервиса `mail` при первом запуске (в Jambotron: проект → Сервисы → `mail` → Логи) и лежат в файле `/app/data/initial-passwords.txt` контейнера `mail` (том `data`, права 600): `docker exec <контейнер mail> cat /app/data/initial-passwords.txt`. Прочитать и сменить пароль в настройках.
|
||||
- Регистрация открыта (`MAIL_REGISTRATION=true`): любой может завести ящик в `MAIL_DOMAINS`. Наружу письма не уходят, поэтому для тестового стенда это безопасно; закрыть — `MAIL_REGISTRATION=false`.
|
||||
- Наружу открыты только веб-почта и API. SMTP (3025) — внутри сети ВМ, наружу ничего не отправляется (`OUTBOUND_MODE=off`).
|
||||
- Домен ящиков — `MAIL_DOMAINS` (по умолчанию `mail.test`).
|
||||
@@ -113,15 +113,60 @@ cd frontend && npm install && npx ng serve # http://loca
|
||||
|
||||
Образ собирается из `backend/Dockerfile` в три этапа: Angular → Spring Boot (веб-клиент внутри jar) → JRE не от root.
|
||||
|
||||
### Готовый Docker-образ
|
||||
|
||||
Как у htop-web: Drone (`.drone.yml`) собирает `backend/Dockerfile`. На PR образ только собирается, на `master` уходит в реестр Gitea:
|
||||
|
||||
```
|
||||
gitea.jambotron.com/jambotron_gang/reactive-mail-server:latest
|
||||
gitea.jambotron.com/jambotron_gang/reactive-mail-server:<первые 8 символов коммита master>
|
||||
```
|
||||
|
||||
Реестр публичный: `docker pull` без логина.
|
||||
|
||||
`docker-compose.image.yml` — тот же стенд, что `docker-compose.jambotron.yml`, но сервис `mail` берётся из образа, на ВМ ничего не собирается (деплой быстрее, Node и Gradle на ВМ не нужны). В мастере деплоя Jambotron выбрать этот файл; домен — сервису `mail`, порт `8080`. По умолчанию `latest`, он скачивается заново при каждом деплое (`pull_policy: always`); закрепить сборку — переменная `MAIL_IMAGE_TAG=<8 символов коммита>`.
|
||||
|
||||
```bash
|
||||
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).
|
||||
|
||||
## Лицензия
|
||||
|
||||
|
||||
@@ -28,8 +28,8 @@ import java.util.Locale;
|
||||
* <li>the catch-all mailbox ({@code mailserver.catch-all}) if it does not exist yet — mail to unknown addresses is
|
||||
* stored there, so it must exist.</li>
|
||||
* </ul>
|
||||
* A generated password is appended to {@code mailserver.passwords-file} (mode 600, in the data volume) — read it once
|
||||
* and change the password. Without the file setting it is printed to the log once.
|
||||
* A generated password is printed to the log once (visible in Jambotron: project → Services → mail → Logs) and appended
|
||||
* to {@code mailserver.passwords-file} (mode 600, in the data volume) — change it after the first login.
|
||||
*/
|
||||
@Configuration
|
||||
public class DataInitializer {
|
||||
@@ -63,7 +63,8 @@ public class DataInitializer {
|
||||
log.info("Mailbox created: {}", address);
|
||||
} else if (file != null) {
|
||||
appendPassword(file, address, secret);
|
||||
log.warn("Mailbox created: {} — password in {} (change it)", address, file);
|
||||
// also in the log: the owner sees it in Jambotron (project → Services → mail → Logs) without a shell
|
||||
log.warn("Mailbox created: {} — password: {} (also in {}; change it)", address, secret, file);
|
||||
} else {
|
||||
log.warn("Mailbox created: {} — password: {} (shown once, change it)", address, secret);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
* <p>
|
||||
* 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<String, Material> 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;
|
||||
}
|
||||
}
|
||||
@@ -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):
|
||||
* <ul>
|
||||
* <li>{@code cert} and {@code key} given (PEM: certificate chain and PKCS#8 key, e.g. from Let's Encrypt) — used as is;</li>
|
||||
* <li>{@code cert} and {@code key} given (PEM: certificate chain and the key — PKCS#8, RSA or EC, e.g. from Let's Encrypt) —
|
||||
* used as is and read again when the files change ({@link ReloadingKeyManager}, checked once a minute);</li>
|
||||
* <li>otherwise a self-signed certificate for the server hostname, created once in {@code dir} and reused —
|
||||
* enough for encryption between our own services; other servers deliver over it too (MX delivery does not
|
||||
* check certificates, RFC 7435 opportunistic TLS).</li>
|
||||
@@ -41,6 +43,7 @@ public final class SmtpTls {
|
||||
private static final Logger log = LoggerFactory.getLogger(SmtpTls.class);
|
||||
static final String CERT_FILE = "smtp-cert.pem";
|
||||
static final String KEY_FILE = "smtp-key.pem";
|
||||
static final Duration RELOAD_CHECK = Duration.ofMinutes(1);
|
||||
|
||||
private SmtpTls() {
|
||||
}
|
||||
@@ -67,7 +70,8 @@ public final class SmtpTls {
|
||||
log.info("SMTP TLS: self-signed certificate {}", cert);
|
||||
}
|
||||
}
|
||||
return SslContextBuilder.forServer(cert.toFile(), key.toFile())
|
||||
// Read again when the files change (a renewed Let's Encrypt certificate) — no restart needed
|
||||
return SslContextBuilder.forServer(new ReloadingKeyManager(cert, key, RELOAD_CHECK, Clock.systemUTC()))
|
||||
.protocols("TLSv1.3", "TLSv1.2")
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.mailserver.smtp;
|
||||
|
||||
import org.bouncycastle.asn1.x500.X500Name;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
|
||||
import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
|
||||
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.StringWriter;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.FileTime;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Date;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ReloadingKeyManagerTest {
|
||||
|
||||
/** A clock moved by hand */
|
||||
static final class TestClock extends Clock {
|
||||
Instant now = Instant.parse("2026-11-06T12:00:00Z");
|
||||
|
||||
@Override
|
||||
public ZoneOffset getZone() {
|
||||
return ZoneOffset.UTC;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Clock withZone(java.time.ZoneId zone) {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Instant instant() {
|
||||
return now;
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsTheFilesAgainWhenTheyChange(@TempDir Path dir) throws Exception {
|
||||
Path cert = dir.resolve("cert.pem");
|
||||
Path key = dir.resolve("key.pem");
|
||||
SmtpTls.selfSigned("old.example", cert, key);
|
||||
TestClock clock = new TestClock();
|
||||
ReloadingKeyManager manager = new ReloadingKeyManager(cert, key, Duration.ofMinutes(1), clock);
|
||||
|
||||
String first = manager.chooseEngineServerAlias("RSA", null, null);
|
||||
assertEquals("CN=old.example", subject(manager.getCertificateChain(first)));
|
||||
assertNull(manager.chooseEngineServerAlias("EC", null, null), "an RSA key is not offered for EC");
|
||||
|
||||
SmtpTls.selfSigned("new.example", cert, key);
|
||||
Files.setLastModifiedTime(cert, FileTime.from(Instant.now().plusSeconds(5)));
|
||||
Files.setLastModifiedTime(key, FileTime.from(Instant.now().plusSeconds(5)));
|
||||
|
||||
assertEquals(first, manager.chooseEngineServerAlias("RSA", null, null), "not checked before the interval");
|
||||
|
||||
clock.now = clock.now.plus(Duration.ofMinutes(1));
|
||||
String second = manager.chooseEngineServerAlias("RSA", null, null);
|
||||
assertNotEquals(first, second);
|
||||
assertEquals("CN=new.example", subject(manager.getCertificateChain(second)));
|
||||
assertNotNull(manager.getPrivateKey(second));
|
||||
assertNotNull(manager.getCertificateChain(first), "a handshake that holds the previous alias still finishes");
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsTheOldCertificateWhenTheNewFileIsBroken(@TempDir Path dir) throws Exception {
|
||||
Path cert = dir.resolve("cert.pem");
|
||||
Path key = dir.resolve("key.pem");
|
||||
SmtpTls.selfSigned("old.example", cert, key);
|
||||
TestClock clock = new TestClock();
|
||||
ReloadingKeyManager manager = new ReloadingKeyManager(cert, key, Duration.ofMinutes(1), clock);
|
||||
|
||||
Files.writeString(cert, "-----BEGIN CERTIFICATE-----\nhalf");
|
||||
Files.setLastModifiedTime(cert, FileTime.from(Instant.now().plusSeconds(5)));
|
||||
clock.now = clock.now.plus(Duration.ofMinutes(2));
|
||||
|
||||
String alias = manager.chooseEngineServerAlias("RSA", null, null);
|
||||
assertEquals("CN=old.example", subject(manager.getCertificateChain(alias)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsTraditionalEcKeysAsLegoWritesThem(@TempDir Path dir) throws Exception {
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
|
||||
generator.initialize(256);
|
||||
KeyPair pair = generator.generateKeyPair();
|
||||
X500Name name = new X500Name("CN=mail.example");
|
||||
X509Certificate certificate = new JcaX509CertificateConverter().getCertificate(
|
||||
new JcaX509v3CertificateBuilder(name, BigInteger.ONE, new Date(System.currentTimeMillis() - 86_400_000L),
|
||||
new Date(System.currentTimeMillis() + 86_400_000L), name, pair.getPublic())
|
||||
.build(new JcaContentSignerBuilder("SHA256withECDSA").build(pair.getPrivate())));
|
||||
Path cert = dir.resolve("cert.pem");
|
||||
Path key = dir.resolve("key.pem");
|
||||
Files.writeString(cert, pem(certificate));
|
||||
Files.writeString(key, sec1(pair)); // -----BEGIN EC PRIVATE KEY----- (SEC1 with the curve), not PKCS#8
|
||||
assertTrue(Files.readString(key).contains("EC PRIVATE KEY"));
|
||||
|
||||
ReloadingKeyManager manager = new ReloadingKeyManager(cert, key, Duration.ofMinutes(1), new TestClock());
|
||||
String alias = manager.chooseEngineServerAlias("EC", null, null);
|
||||
assertNotNull(alias);
|
||||
assertEquals("EC", manager.getPrivateKey(alias).getAlgorithm());
|
||||
assertNull(manager.chooseEngineServerAlias("RSA", null, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesAKeyThatDoesNotMatchTheCertificateType(@TempDir Path dir) throws Exception {
|
||||
Path cert = dir.resolve("cert.pem");
|
||||
Path key = dir.resolve("key.pem");
|
||||
SmtpTls.selfSigned("rsa.example", cert, key);
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
|
||||
generator.initialize(256);
|
||||
Files.writeString(key, sec1(generator.generateKeyPair()));
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> new ReloadingKeyManager(cert, key, Duration.ofMinutes(1), new TestClock()));
|
||||
}
|
||||
|
||||
/** As lego and openssl ecparam -genkey write it: SEC1 with the named curve secp256r1 */
|
||||
private static String sec1(KeyPair pair) throws Exception {
|
||||
java.security.interfaces.ECPrivateKey key = (java.security.interfaces.ECPrivateKey) pair.getPrivate();
|
||||
org.bouncycastle.asn1.sec.ECPrivateKey sec1 = new org.bouncycastle.asn1.sec.ECPrivateKey(256, key.getS(),
|
||||
new org.bouncycastle.asn1.x9.X962Parameters(org.bouncycastle.asn1.sec.SECObjectIdentifiers.secp256r1));
|
||||
StringWriter out = new StringWriter();
|
||||
try (org.bouncycastle.util.io.pem.PemWriter writer = new org.bouncycastle.util.io.pem.PemWriter(out)) {
|
||||
writer.writeObject(new org.bouncycastle.util.io.pem.PemObject("EC PRIVATE KEY", sec1.getEncoded()));
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
private static String subject(X509Certificate[] chain) {
|
||||
return chain[0].getSubjectX500Principal().getName();
|
||||
}
|
||||
|
||||
private static String pem(Object object) throws Exception {
|
||||
StringWriter out = new StringWriter();
|
||||
try (JcaPEMWriter writer = new JcaPEMWriter(out)) {
|
||||
writer.writeObject(object);
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
# Same stand as docker-compose.jambotron.yml, but from the ready image — nothing is built on the VM:
|
||||
# gitea.jambotron.com/jambotron_gang/reactive-mail-server (Drone builds it from master, see .drone.yml).
|
||||
# Pin a build: MAIL_IMAGE_TAG=<first 8 chars of the master commit>; default latest (pulled again on every deploy).
|
||||
# Test deploy on Jambotron (v0.2.3 75.3): pick this file in the deploy wizard.
|
||||
# Outside, only the web client + API (service "mail", port 8080) through Jambotron's Caddy — give it a domain.
|
||||
# SMTP (3025) stays inside the VM's docker network; nothing is sent outside (OUTBOUND_MODE=off).
|
||||
# Secrets are generated on the first start into the "secrets" volume — nothing to type in, nothing in git.
|
||||
# Mailboxes admin@<MAIL_DOMAINS> and trap@mail.test (catch-all): passwords in the "mail" log on the first start
|
||||
# (Jambotron: project → Services → mail → Logs) and in /app/data/initial-passwords.txt
|
||||
# of the "mail" container (docker exec <mail container> cat /app/data/initial-passwords.txt).
|
||||
name: reactive-mail
|
||||
|
||||
services:
|
||||
secrets-init:
|
||||
image: alpine:3.20
|
||||
restart: "no"
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
umask 077
|
||||
for name in DB_PASSWORD JWT_SECRET; do
|
||||
[ -s /secrets/$$name ] || head -c 48 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' > /secrets/$$name
|
||||
done
|
||||
# postgres reads it as uid 70, the app as uid 10001 — readable inside these containers only
|
||||
chmod 644 /secrets/DB_PASSWORD /secrets/JWT_SECRET
|
||||
volumes:
|
||||
- secrets:/secrets
|
||||
|
||||
db:
|
||||
# Debian, not -alpine: on Jambotron staging VMs (Alpine 3.24, kernel 6.18) the Alpine build of postgres
|
||||
# dies with "Bus error" even on --version; the Debian build works (2026-11-05)
|
||||
image: postgres:17
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
secrets-init:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
POSTGRES_DB: mailserver
|
||||
POSTGRES_USER: mail
|
||||
POSTGRES_PASSWORD_FILE: /run/mail-secrets/DB_PASSWORD
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- secrets:/run/mail-secrets:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U mail -d mailserver"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
mem_limit: 256m
|
||||
|
||||
mail:
|
||||
image: gitea.jambotron.com/jambotron_gang/reactive-mail-server:${MAIL_IMAGE_TAG:-latest}
|
||||
pull_policy: always
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
expose:
|
||||
- "8080" # web client + API → domain in Jambotron
|
||||
- "3025" # SMTP, inside the network only
|
||||
- "3143" # IMAP (STARTTLS), inside the network only — open it in Jambotron as an internal TCP port
|
||||
environment:
|
||||
DB_HOST: db
|
||||
MAILSERVER_SECRETS_DIR: /run/mail-secrets
|
||||
MAIL_DOMAINS: ${MAIL_DOMAINS:-mail.test}
|
||||
MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.test}
|
||||
DKIM_DOMAIN: ${MAIL_DOMAINS:-mail.test}
|
||||
# mail trap: mail to any address without its own mailbox here (also gmail.com etc.) goes into this mailbox
|
||||
MAIL_CATCH_ALL: ${MAIL_CATCH_ALL:-trap@mail.test}
|
||||
MAIL_REGISTRATION: ${MAIL_REGISTRATION:-true} # test stand: anyone can create a mailbox in MAIL_DOMAINS (nothing goes outside)
|
||||
OUTBOUND_MODE: "off"
|
||||
SMTP_AUTH_WITHOUT_TLS: "true" # SMTP is not reachable from outside the VM
|
||||
SPF_ENABLED: "false"
|
||||
DMARC_ENABLED: "false"
|
||||
volumes:
|
||||
- data:/app/data
|
||||
- secrets:/run/mail-secrets:ro
|
||||
mem_limit: 512m
|
||||
|
||||
volumes:
|
||||
secrets:
|
||||
pgdata:
|
||||
data:
|
||||
@@ -2,7 +2,8 @@
|
||||
# Outside, only the web client + API (service "mail", port 8080) through Jambotron's Caddy — give it a domain.
|
||||
# SMTP (3025) stays inside the VM's docker network; nothing is sent outside (OUTBOUND_MODE=off).
|
||||
# Secrets are generated on the first start into the "secrets" volume — nothing to type in, nothing in git.
|
||||
# Mailboxes admin@<MAIL_DOMAINS> and trap@mail.test (catch-all): passwords in /app/data/initial-passwords.txt
|
||||
# Mailboxes admin@<MAIL_DOMAINS> and trap@mail.test (catch-all): passwords in the "mail" log on the first start
|
||||
# (Jambotron: project → Services → mail → Logs) and in /app/data/initial-passwords.txt
|
||||
# of the "mail" container (docker exec <mail container> cat /app/data/initial-passwords.txt).
|
||||
name: reactive-mail
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
# Real mail on the internet (v0.2.3 75.6): receives mail for MAIL_DOMAINS and sends it straight to the recipients'
|
||||
# MX servers (port 25), DKIM-signed. From the ready image, like docker-compose.image.yml.
|
||||
#
|
||||
# Needs a VM of its own with a public address (Jambotron 60.8: «Публичный адрес» of the VM, SMTP «да» — port 25
|
||||
# is closed by default) — the ports below are published on that address:
|
||||
# 25 SMTP: mail from other servers (STARTTLS) and from mail programs
|
||||
# 587 submission: mail programs (STARTTLS + login) — the same SMTP listener as 25
|
||||
# 465 SMTPS: mail programs, implicit TLS
|
||||
# 993 IMAPS: mail programs, implicit TLS
|
||||
# 80 only for Let's Encrypt (HTTP-01) — service "acme"
|
||||
# The web client + API (service "mail", port 8080) goes through Jambotron's Caddy under ANOTHER name
|
||||
# (webmail.<domain>): the name MAIL_HOSTNAME points at the VM's public address, not at Caddy.
|
||||
#
|
||||
# DNS before the first start (the certificate is issued over HTTP on MAIL_HOSTNAME):
|
||||
# A mail.<domain> → public address of the VM
|
||||
# PTR public address → mail.<domain> (hoster's panel: Reverse DNS)
|
||||
# MX <domain> → 10 mail.<domain>
|
||||
# TXT <domain> → "v=spf1 ip4:<public address> -all"
|
||||
# TXT _dmarc.<domain> → "v=DMARC1; p=none; rua=mailto:postmaster@<domain>"
|
||||
# TXT mail._domainkey.<domain> → printed in the "mail" log on the first start (DKIM key, kept in the data volume)
|
||||
#
|
||||
# Mailbox admin@<domain>: its password is in the "mail" log on the first start and in /app/data/initial-passwords.txt.
|
||||
# Registration is closed: new mailboxes only by the admin. No catch-all.
|
||||
name: reactive-mail-public
|
||||
|
||||
services:
|
||||
secrets-init:
|
||||
image: alpine:3.20
|
||||
restart: "no"
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
umask 077
|
||||
for name in DB_PASSWORD JWT_SECRET; do
|
||||
[ -s /secrets/$$name ] || head -c 48 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' > /secrets/$$name
|
||||
done
|
||||
chmod 644 /secrets/DB_PASSWORD /secrets/JWT_SECRET
|
||||
volumes:
|
||||
- secrets:/secrets
|
||||
|
||||
# Let's Encrypt for MAIL_HOSTNAME: issued on the first start, renewed 30 days before the end (checked twice a day).
|
||||
# The deploy hook copies the pair to /tls (readable by the "mail" user 10001); the server reads it again within
|
||||
# a minute — no restart
|
||||
acme:
|
||||
image: goacme/lego:v5.5.2
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
entrypoint:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
cat > /usr/local/bin/jb-deploy <<'EOF'
|
||||
#!/bin/sh
|
||||
set -e
|
||||
cp "$$LEGO_HOOK_CERT_PATH" /tls/cert.pem.new
|
||||
cp "$$LEGO_HOOK_CERT_KEY_PATH" /tls/key.pem.new
|
||||
chown 10001 /tls/cert.pem.new /tls/key.pem.new
|
||||
chmod 600 /tls/key.pem.new
|
||||
chmod 644 /tls/cert.pem.new
|
||||
mv /tls/key.pem.new /tls/key.pem
|
||||
mv /tls/cert.pem.new /tls/cert.pem
|
||||
echo "certificate for $$LEGO_HOOK_CERT_DOMAINS copied to /tls"
|
||||
EOF
|
||||
chmod 755 /usr/local/bin/jb-deploy
|
||||
while true; do
|
||||
/lego run --accept-tos --email "$$ACME_EMAIL" --domains "$$MAIL_HOSTNAME" --key-type EC256 \
|
||||
--http --http.address :80 --path /var/lib/lego --renew-days 30 --no-random-sleep \
|
||||
--deploy-hook /usr/local/bin/jb-deploy || echo "lego failed — next try in 1 hour"
|
||||
[ -s /tls/cert.pem ] && sleep 43200 || sleep 3600
|
||||
done
|
||||
environment:
|
||||
MAIL_HOSTNAME: ${MAIL_HOSTNAME:?MAIL_HOSTNAME, e.g. mail.jambocheck.com}
|
||||
ACME_EMAIL: ${ACME_EMAIL:-postmaster@${MAIL_DOMAINS}}
|
||||
LEGO_LOG_FORMAT: text
|
||||
volumes:
|
||||
- lego:/var/lib/lego
|
||||
- tls:/tls
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "test -s /tls/cert.pem && test -s /tls/key.pem"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
start_period: 10m
|
||||
retries: 3
|
||||
mem_limit: 128m
|
||||
|
||||
db:
|
||||
image: postgres:17
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
secrets-init:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
POSTGRES_DB: mailserver
|
||||
POSTGRES_USER: mail
|
||||
POSTGRES_PASSWORD_FILE: /run/mail-secrets/DB_PASSWORD
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- secrets:/run/mail-secrets:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U mail -d mailserver"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
mem_limit: 256m
|
||||
|
||||
mail:
|
||||
image: gitea.jambotron.com/jambotron_gang/reactive-mail-server:${MAIL_IMAGE_TAG:-latest}
|
||||
pull_policy: always
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
acme:
|
||||
condition: service_healthy # waits for the first certificate
|
||||
ports:
|
||||
- "25:3025"
|
||||
- "587:3025"
|
||||
- "465:3465"
|
||||
- "993:3993"
|
||||
expose:
|
||||
- "8080" # web client + API → webmail.<domain> in Jambotron
|
||||
environment:
|
||||
DB_HOST: db
|
||||
MAILSERVER_SECRETS_DIR: /run/mail-secrets
|
||||
MAIL_DOMAINS: ${MAIL_DOMAINS:?MAIL_DOMAINS, e.g. jambocheck.com}
|
||||
MAIL_HOSTNAME: ${MAIL_HOSTNAME:?MAIL_HOSTNAME, e.g. mail.jambocheck.com}
|
||||
MAIL_TLS_CERT: /tls/cert.pem
|
||||
MAIL_TLS_KEY: /tls/key.pem
|
||||
SMTPS_PORT: "3465"
|
||||
IMAPS_PORT: "3993"
|
||||
DKIM_DOMAIN: ${MAIL_DOMAINS}
|
||||
DKIM_SELECTOR: mail
|
||||
DKIM_KEYS_DIR: /app/data/dkim-keys
|
||||
MAIL_CATCH_ALL: ""
|
||||
MAIL_REGISTRATION: "false"
|
||||
OUTBOUND_MODE: mx
|
||||
SMTP_AUTH_WITHOUT_TLS: "false"
|
||||
IMAP_AUTH_WITHOUT_TLS: "false"
|
||||
SPF_ENABLED: "true"
|
||||
DMARC_ENABLED: "true"
|
||||
volumes:
|
||||
- data:/app/data
|
||||
- secrets:/run/mail-secrets:ro
|
||||
- tls:/tls:ro
|
||||
mem_limit: 512m
|
||||
|
||||
volumes:
|
||||
secrets:
|
||||
pgdata:
|
||||
data:
|
||||
lego:
|
||||
tls:
|
||||
Reference in New Issue
Block a user