Веб-почта по HTTPS на том же адресе: https://mail.jambocheck.com (порт 443)
continuous-integration/drone/pr Build is passing

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 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 02:18:42 +01:00
co-authored by Claude Opus 5.5
parent 4ad2e44e9b
commit f9b929ac7a
5 changed files with 134 additions and 2 deletions
@@ -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();
}
}
}
@@ -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)
@@ -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();
}
}