From f9b929ac7aff75238d8bd7ee5a675a087ade6143 Mon Sep 17 00:00:00 2001 From: Liosha84 Date: Sat, 7 Nov 2026 02:18:42 +0100 Subject: [PATCH] =?UTF-8?q?=D0=92=D0=B5=D0=B1-=D0=BF=D0=BE=D1=87=D1=82?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BE=20HTTPS=20=D0=BD=D0=B0=20=D1=82=D0=BE?= =?UTF-8?q?=D0=BC=20=D0=B6=D0=B5=20=D0=B0=D0=B4=D1=80=D0=B5=D1=81=D0=B5:?= =?UTF-8?q?=20https://mail.jambocheck.com=20(=D0=BF=D0=BE=D1=80=D1=82=2044?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HTTPS_PORT — второй Netty-сервер с тем же HttpHandler и тем же сертификатом, что SMTP/IMAP (Let's Encrypt от acme, перечитывается при продлении). HSTS добавляет Spring Security. docker-compose.public.yml: 443 → 8443. HTTP 8080 остаётся для прокси внутри сети. Тесты: HttpsConfigTest, всего 74; живой запуск: /api/config по HTTPS, HSTS. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + .../com/mailserver/config/HttpsConfig.java | 81 +++++++++++++++++++ .../src/main/resources/application.properties | 2 + .../mailserver/config/HttpsConfigTest.java | 45 +++++++++++ docker-compose.public.yml | 7 +- 5 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 backend/src/main/java/com/mailserver/config/HttpsConfig.java create mode 100644 backend/src/test/java/com/mailserver/config/HttpsConfigTest.java diff --git a/README.md b/README.md index d9c3fc3..fa62c1d 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ API: `GET /api/config` (без входа), `/api/me`, `/api/me/password`, `/api | `MAIL_HOSTNAME` | имя сервера в SMTP | `localhost` | | `MAIL_ADMIN_EMAIL`, `MAIL_ADMIN_PASSWORD` | первый ящик на пустой базе; без пароля — случайный (в `MAIL_PASSWORDS_FILE` или один раз в логе) | `admin@<первый домен>` | | `MAIL_ADMINS` | кто управляет ящиками (Настройки → Mailboxes: создать, новый пароль, выключить), через запятую | первый ящик (`MAIL_ADMIN_EMAIL` или `admin@<первый домен>`) | +| `HTTPS_PORT` | веб-клиент и API по HTTPS тем же сертификатом, что SMTP/IMAP (в `docker-compose.public.yml` — 8443 → 443: `https://mail.<домен>`); 0 — только HTTP на `server.port` | 0 | | `MAIL_PASSWORDS_FILE` | файл (права 600), куда дописываются случайные пароли созданных ящиков: первого и ловушки | — (в образе `/app/data/initial-passwords.txt`) | | `MAIL_REGISTRATION` | открыть регистрацию | `false` | | `MAIL_CATCH_ALL` | ящик-ловушка: письма на адреса без своего ящика здесь (и на чужие домены) — сюда, наружу ничего; ящик создаётся при старте | — | diff --git a/backend/src/main/java/com/mailserver/config/HttpsConfig.java b/backend/src/main/java/com/mailserver/config/HttpsConfig.java new file mode 100644 index 0000000..9963c4d --- /dev/null +++ b/backend/src/main/java/com/mailserver/config/HttpsConfig.java @@ -0,0 +1,81 @@ +package com.mailserver.config; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.context.SmartLifecycle; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.server.reactive.HttpHandler; +import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter; +import reactor.netty.DisposableServer; +import reactor.netty.http.server.HttpServer; + +import java.time.Duration; + +/** + * The web client and API over HTTPS on the mail host itself (mailserver.https.port, in public compose 8443 → + * published 443): https://mail.jambocheck.com opens the web mail, no second name through Jambotron's Caddy. + * Same certificate as SMTP/IMAP (Let's Encrypt from the acme service, reloaded when renewed). The plain HTTP port + * (8080) stays for a reverse proxy inside the network (staging: domain in Jambotron). + */ +@Configuration +public class HttpsConfig { + + @Bean + @ConditionalOnExpression("${mailserver.https.port:0} > 0 and ${mailserver.tls.enabled:true}") + public HttpsServer httpsServer(HttpHandler handler, MailServerConfig.SmtpTlsContext tls, + @Value("${mailserver.smtp.host:0.0.0.0}") String host, + @Value("${mailserver.https.port}") int port) { + return new HttpsServer(handler, tls, host, port); + } + + public static class HttpsServer implements SmartLifecycle { + + private static final Logger log = LoggerFactory.getLogger(HttpsServer.class); + + private final HttpHandler handler; + private final MailServerConfig.SmtpTlsContext tls; + private final String host; + private final int port; + private volatile DisposableServer server; + + HttpsServer(HttpHandler handler, MailServerConfig.SmtpTlsContext tls, String host, int port) { + this.handler = handler; + this.tls = tls; + this.host = host; + this.port = port; + } + + @Override + public void start() { + server = HttpServer.create() + .host(host) + .port(port) + .secure(spec -> spec.sslContext(tls.context())) + // HSTS: Spring Security adds it for HTTPS requests by itself + .handle(new ReactorHttpHandlerAdapter(handler)) + .bindNow(Duration.ofSeconds(30)); + log.info("HTTPS (web client + API) listening on {}:{}", host, server.port()); + } + + @Override + public void stop() { + DisposableServer current = server; + if (current != null) { + current.disposeNow(Duration.ofSeconds(10)); + server = null; + } + } + + @Override + public boolean isRunning() { + return server != null; + } + + public int port() { + return server == null ? -1 : server.port(); + } + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 110665b..bec04b3 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -49,6 +49,8 @@ mailserver.tls.enabled=${MAIL_TLS:true} mailserver.tls.cert-file=${MAIL_TLS_CERT:} mailserver.tls.key-file=${MAIL_TLS_KEY:} mailserver.tls.dir=${MAIL_TLS_DIR:./tls} +# Web client + API over HTTPS with the same certificate (public compose: 8443 → 443); 0 — only plain HTTP (server.port) +mailserver.https.port=${HTTPS_PORT:0} mailserver.smtps.port=${SMTPS_PORT:0} # Outbound: off | mx (straight to recipients' MX, port 25) | smarthost (via a provider, e.g. Resend) diff --git a/backend/src/test/java/com/mailserver/config/HttpsConfigTest.java b/backend/src/test/java/com/mailserver/config/HttpsConfigTest.java new file mode 100644 index 0000000..b51378e --- /dev/null +++ b/backend/src/test/java/com/mailserver/config/HttpsConfigTest.java @@ -0,0 +1,45 @@ +package com.mailserver.config; + +import com.mailserver.smtp.SmtpTls; +import io.netty.handler.ssl.SslContextBuilder; +import io.netty.handler.ssl.util.InsecureTrustManagerFactory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.reactive.HttpHandler; +import reactor.core.publisher.Mono; +import reactor.netty.http.client.HttpClient; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +/** https://mail.jambocheck.com: the web client over HTTPS with the mail certificate. */ +class HttpsConfigTest { + + @Test + void servesTheWebAppOverTls(@TempDir Path dir) throws Exception { + MailServerConfig.SmtpTlsContext tls = new MailServerConfig.SmtpTlsContext(SmtpTls.serverContext("", "", dir, "localhost")); + HttpHandler handler = (request, response) -> { + response.setStatusCode(HttpStatus.OK); + return response.writeWith(Mono.just(DefaultDataBufferFactory.sharedInstance + .wrap(("hello " + request.getURI().getScheme()).getBytes(StandardCharsets.UTF_8)))); + }; + io.netty.handler.ssl.SslContext trustAll = SslContextBuilder.forClient().trustManager(InsecureTrustManagerFactory.INSTANCE).build(); + HttpsConfig.HttpsServer server = new HttpsConfig.HttpsServer(handler, tls, "127.0.0.1", 0); + server.start(); + try { + assertThat(server.isRunning()).isTrue(); + String body = HttpClient.create() + .secure(spec -> spec.sslContext(trustAll)) + .get().uri("https://127.0.0.1:" + server.port() + "/") + .responseContent().aggregate().asString().block(); + assertThat(body).isEqualTo("hello https"); + } finally { + server.stop(); + } + assertThat(server.isRunning()).isFalse(); + } +} diff --git a/docker-compose.public.yml b/docker-compose.public.yml index c0f41c6..d2769e9 100644 --- a/docker-compose.public.yml +++ b/docker-compose.public.yml @@ -8,7 +8,8 @@ # 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 +# 443 web client + API over HTTPS, same Let's Encrypt certificate (acme): https://mail.jambocheck.com +# The web client + API can also go through Jambotron's Caddy (plain port 8080) under ANOTHER name # (webmail.): 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): @@ -121,8 +122,9 @@ services: - "587:3025" - "465:3465" - "993:3993" + - "443:8443" # web client + API over HTTPS: https://mail.jambocheck.com expose: - - "8080" # web client + API → webmail. in Jambotron + - "8080" # plain HTTP, for a reverse proxy inside the network (not needed on the public address) environment: DB_HOST: db MAILSERVER_SECRETS_DIR: /run/mail-secrets @@ -132,6 +134,7 @@ services: MAIL_TLS_KEY: /tls/key.pem SMTPS_PORT: "3465" IMAPS_PORT: "3993" + HTTPS_PORT: "8443" DKIM_DOMAIN: ${MAIL_DOMAINS:-jambocheck.com} DKIM_SELECTOR: mail DKIM_KEYS_DIR: /app/data/dkim-keys