Настоящая почта: 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,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