Files
reactive-mail-server/backend/src/main/java/com/mailserver/smtp/SmtpTls.java
T
Liosha84andClaude Opus 5.5 711807847e
continuous-integration/drone/pr Build is passing
Настоящая почта: docker-compose.public.yml (MX, DKIM, Let's Encrypt через lego), сертификат перечитывается без перезапуска
- ReloadingKeyManager: сертификат и ключ из PEM (PKCS#8, RSA, EC/SEC1 как у lego), раз в минуту
  проверяет файлы и перечитывает их; битый файл оставляет прежний сертификат; поколения по алиасам.
- docker-compose.public.yml: порты 25/587/465/993 на публичном адресе ВМ, OUTBOUND_MODE=mx, DKIM,
  SPF/DMARC, регистрация закрыта, без ловушки; сервис acme (lego v5.5.2, HTTP-01 на :80, EC256,
  продление за 30 дней, хук кладёт пару в том tls для uid 10001).
- README: раздел «Настоящая почта», DNS-записи, перечитка сертификата.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 13:10:04 +01:00

115 lines
5.1 KiB
Java

package com.mailserver.smtp;
import io.netty.handler.ssl.SslContext;
import io.netty.handler.ssl.SslContextBuilder;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.asn1.x509.GeneralName;
import org.bouncycastle.asn1.x509.GeneralNames;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermissions;
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;
import java.util.Date;
/**
* Certificate for SMTP TLS (STARTTLS and implicit TLS):
* <ul>
* <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>
* </ul>
*/
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() {
}
public static SslContext serverContext(String certFile, String keyFile, Path dir, String hostname) throws Exception {
boolean givenCert = certFile != null && !certFile.isBlank();
boolean givenKey = keyFile != null && !keyFile.isBlank();
if (givenCert != givenKey) {
throw new IllegalStateException("TLS: set both the certificate and the key file, or neither");
}
Path cert;
Path key;
if (givenCert) {
cert = Path.of(certFile);
key = Path.of(keyFile);
log.info("SMTP TLS: certificate {}", cert);
} else {
cert = dir.resolve(CERT_FILE);
key = dir.resolve(KEY_FILE);
if (!Files.exists(cert) || !Files.exists(key)) {
selfSigned(hostname, cert, key);
log.warn("SMTP TLS: created a self-signed certificate for {} in {}", hostname, dir);
} else {
log.info("SMTP TLS: self-signed certificate {}", cert);
}
}
// 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();
}
static void selfSigned(String hostname, Path certFile, Path keyFile) throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048, new SecureRandom());
KeyPair pair = generator.generateKeyPair();
X500Name name = new X500Name("CN=" + hostname);
Instant now = Instant.now();
JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder(name,
new BigInteger(64, new SecureRandom()), Date.from(now.minus(Duration.ofDays(1))),
Date.from(now.plus(Duration.ofDays(3650))), name, pair.getPublic());
builder.addExtension(Extension.subjectAlternativeName, false,
new GeneralNames(new GeneralName(GeneralName.dNSName, hostname)));
X509Certificate certificate = new JcaX509CertificateConverter()
.getCertificate(builder.build(new JcaContentSignerBuilder("SHA256withRSA").build(pair.getPrivate())));
Files.createDirectories(keyFile.toAbsolutePath().getParent());
write(keyFile, pem("PRIVATE KEY", pair.getPrivate().getEncoded()), true);
write(certFile, pem("CERTIFICATE", certificate.getEncoded()), false);
}
private static String pem(String type, byte[] der) {
return "-----BEGIN " + type + "-----\n"
+ Base64.getMimeEncoder(64, new byte[]{'\n'}).encodeToString(der)
+ "\n-----END " + type + "-----\n";
}
private static void write(Path file, String text, boolean secret) throws IOException {
Files.writeString(file, text, StandardCharsets.US_ASCII);
if (secret) {
try {
Files.setPosixFilePermissions(file, PosixFilePermissions.fromString("rw-------"));
} catch (UnsupportedOperationException ignored) {
// not a POSIX file system
}
}
}
}