Настоящая почта: docker-compose.public.yml (MX, DKIM, Let's Encrypt через lego), сертификат перечитывается без перезапуска
continuous-integration/drone/pr Build is passing
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>
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user