continuous-integration/drone/pr Build is passing
- 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>
115 lines
5.1 KiB
Java
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
|
|
}
|
|
}
|
|
}
|
|
}
|