Настоящая почта: docker-compose.public.yml (MX, DKIM, Let's Encrypt через lego), сертификат перечитывается без перезапуска
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:
2026-11-06 13:10:04 +01:00
co-authored by Claude Opus 5.5
parent a37acfaaa7
commit 711807847e
5 changed files with 535 additions and 6 deletions
@@ -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();
}
}