From 5aedfdb818a4979486428e767c151550d496bc25 Mon Sep 17 00:00:00 2001 From: Steve Oberst <58824185+SteveOberst@users.noreply.github.com> Date: Tue, 19 Aug 2025 13:27:41 +0200 Subject: [PATCH 1/2] Add auto-creation of keystore parent dirs in LetsEncrypt helpers and update config docs --- README.md | 9 +++---- ...ownLetsEncryptChallengeEndpointConfig.java | 24 ++++++++++++++++++- ...ownLetsEncryptChallengeEndpointConfig.java | 24 ++++++++++++++++++- 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 7691a71..cbd4020 100644 --- a/README.md +++ b/README.md @@ -132,11 +132,11 @@ dependencies { | Property | Description | Default value, if any | |------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------|------------------------| | server.ssl.key-store | Path to the KeyStore, where Let's Encrypt certificates and account key are to be stored (or are already there) | | -| server.ssl.key-store | KeyStore type (i.e. PKCS12) | | -| server.ssl.key-store-pasword | Password for KeyStore with Let's Encrypt certificate and account key | | +| server.ssl.key-store-type | KeyStore type (i.e. PKCS12) | | +| server.ssl.key-store-password | Password for KeyStore with Let's Encrypt certificate and account key | | | server.ssl.key-alias | Let's Encrypt certificate key alias in the keystore | | | server.port | Port (secure SSL/TLS) on which your application is deployed | | -| lets-encrypt-helper.domain | Your applications' domain (i.e. example.com) | | +| lets-encrypt-helper.domain | Your application's domain (i.e. example.com) | | | lets-encrypt-helper.contact | The contact of person responsible for the domain (i.e. mailto:john@example.com) | | | lets-encrypt-helper.account-key-alias | Account key alias | letsencrypt-user | | lets-encrypt-helper.letsencrypt-server | Let's Encrypt server to use | acme://letsencrypt.org | @@ -144,10 +144,11 @@ dependencies { | lets-encrypt-helper.update-before-expiry | Start trying to update certificate this time before expiration | P30D (30 days) | | lets-encrypt-helper.busy-wait-interval | Busy wait interval for thread that checks if the certificate is valid | PT1M (1 minute) | | lets-encrypt-helper.account-cert-validity | Validity duration for Account key | P3650D (3650 days) | -| lets-encrypt-helper.store-cert-chain | Store entire trust chain or only domain certificate (for browsers domain ceritificate is enough) | true | +| lets-encrypt-helper.store-cert-chain | Store entire trust chain or only domain certificate (for browsers domain certificate is enough) | true | | lets-encrypt-helper.enabled | Is the helper enabled | true | | lets-encrypt-helper.return-null-model | If challenge endpoint should return null model (i.e. `true` is sane default for cases with Thymeleaf rendering the page) | true | | lets-encrypt-helper.development-only.http01-challenge-port | For development only, port for HTTP-01 ACME challenge | 80 | +| lets-encrypt-helper.auto-create-keystore-dir | Whether to auto-create the parent directory for the keystore if it does not exist | true | ### Example configuration diff --git a/jetty/src/main/java/com/github/valb3r/letsencrypthelper/jetty/JettyWellKnownLetsEncryptChallengeEndpointConfig.java b/jetty/src/main/java/com/github/valb3r/letsencrypthelper/jetty/JettyWellKnownLetsEncryptChallengeEndpointConfig.java index f762df2..6a87b7b 100644 --- a/jetty/src/main/java/com/github/valb3r/letsencrypthelper/jetty/JettyWellKnownLetsEncryptChallengeEndpointConfig.java +++ b/jetty/src/main/java/com/github/valb3r/letsencrypthelper/jetty/JettyWellKnownLetsEncryptChallengeEndpointConfig.java @@ -104,6 +104,7 @@ public class JettyWellKnownLetsEncryptChallengeEndpointConfig implements JettySe private final boolean storeCertChain; private final boolean enabled; private final boolean returnNullModel; + private final boolean autoCreateKeystoreDir; private final ServerProperties serverProperties; // Development only properties, you can't change these for production @@ -143,7 +144,8 @@ public JettyWellKnownLetsEncryptChallengeEndpointConfig( @Value("${lets-encrypt-helper.store-cert-chain:true}") boolean storeCertChain, @Value("${lets-encrypt-helper.enabled:true}") boolean enabled, @Value("${lets-encrypt-helper.return-null-model:true}") boolean returnNullModel, - @Value("${lets-encrypt-helper.development-only.http01-challenge-port:80}") int http01ChallengePort + @Value("${lets-encrypt-helper.development-only.http01-challenge-port:80}") int http01ChallengePort, + @Value("${lets-encrypt-helper.auto-create-keystore-dir:true}") boolean autoCreateKeystoreDir ) { Security.addProvider(new BouncyCastleProvider()); this.serverPort = serverPort; @@ -160,6 +162,7 @@ public JettyWellKnownLetsEncryptChallengeEndpointConfig( this.enabled = enabled; this.returnNullModel = returnNullModel; this.http01ChallengePort = http01ChallengePort; + this.autoCreateKeystoreDir = autoCreateKeystoreDir; if (null == this.serverProperties.getSsl()) { throw new IllegalStateException("SSL is not configured"); @@ -268,6 +271,8 @@ protected Instant getNow() { private void createBasicKeystoreIfMissing() { File keystoreFile = getKeystoreFile(); + ensureParentDirExists(keystoreFile); + if (keystoreFile.exists()) { if (!keystoreFile.canWrite()) { throw new IllegalArgumentException(String.format("Keystore %s is not writable, certificate update is impossible", keystoreFile.getAbsolutePath())); @@ -281,6 +286,23 @@ private void createBasicKeystoreIfMissing() { saveKeystore(keystoreFile, keystore); logger.info("Created basic (dummy cert, real account/domain keys) KeyStore: {}", keystoreFile.getAbsolutePath()); } + + private void ensureParentDirExists(File keystoreFile) { + File parent = keystoreFile.getParentFile(); + if (parent != null && !parent.exists()) { + if (autoCreateKeystoreDir) { + boolean ok = parent.mkdirs(); + if (!ok && !parent.exists()) { + throw new IllegalStateException("Failed to create keystore parent directory: " + parent.getAbsolutePath()); + } + logger.info("Created keystore parent directory: {}", parent.getAbsolutePath()); + } else { + throw new IllegalStateException( + "Keystore parent directory does not exist: " + parent.getAbsolutePath() + + " (set lets-encrypt-helper.create-parent-dirs=true to auto-create)"); + } + } + } private TargetProtocol createObservableProtocol(SslContextFactory contextFactory) { var observe = new TargetProtocol(contextFactory); diff --git a/tomcat/src/main/java/com/github/valb3r/letsencrypthelper/tomcat/TomcatWellKnownLetsEncryptChallengeEndpointConfig.java b/tomcat/src/main/java/com/github/valb3r/letsencrypthelper/tomcat/TomcatWellKnownLetsEncryptChallengeEndpointConfig.java index 0aca5a6..cf66a80 100644 --- a/tomcat/src/main/java/com/github/valb3r/letsencrypthelper/tomcat/TomcatWellKnownLetsEncryptChallengeEndpointConfig.java +++ b/tomcat/src/main/java/com/github/valb3r/letsencrypthelper/tomcat/TomcatWellKnownLetsEncryptChallengeEndpointConfig.java @@ -100,6 +100,7 @@ public class TomcatWellKnownLetsEncryptChallengeEndpointConfig implements Tomcat private final boolean storeCertChain; private final boolean enabled; private final boolean returnNullModel; + private final boolean autoCreateKeystoreDir; private final ServerProperties serverProperties; // Development only properties, you can't change these for production @@ -142,7 +143,8 @@ public TomcatWellKnownLetsEncryptChallengeEndpointConfig( @Value("${lets-encrypt-helper.store-cert-chain:true}") boolean storeCertChain, @Value("${lets-encrypt-helper.enabled:true}") boolean enabled, @Value("${lets-encrypt-helper.return-null-model:true}") boolean returnNullModel, - @Value("${lets-encrypt-helper.development-only.http01-challenge-port:80}") int http01ChallengePort + @Value("${lets-encrypt-helper.development-only.http01-challenge-port:80}") int http01ChallengePort, + @Value("${lets-encrypt-helper.create-parent-dirs:true}") boolean autoCreateKeystoreDir ) { Security.addProvider(new BouncyCastleProvider()); this.serverPort = serverPort; @@ -159,6 +161,7 @@ public TomcatWellKnownLetsEncryptChallengeEndpointConfig( this.enabled = enabled; this.returnNullModel = returnNullModel; this.http01ChallengePort = http01ChallengePort; + this.autoCreateKeystoreDir = autoCreateKeystoreDir; if (null == this.serverProperties.getSsl()) { throw new IllegalStateException("SSL is not configured"); @@ -289,6 +292,8 @@ protected SSLHostConfigCertificate findMatchingCertificate(SSLHostConfig config, private void createBasicKeystoreIfMissing() { File keystoreFile = getKeystoreFile(); + ensureParentDirExists(keystoreFile); + if (keystoreFile.exists()) { if (!keystoreFile.canWrite()) { throw new IllegalArgumentException(String.format("Keystore %s is not writable, certificate update is impossible", keystoreFile.getAbsolutePath())); @@ -302,6 +307,23 @@ private void createBasicKeystoreIfMissing() { saveKeystore(keystoreFile, keystore); logger.info("Created basic (dummy cert, real account/domain keys) KeyStore: {}", keystoreFile.getAbsolutePath()); } + + private void ensureParentDirExists(File keystoreFile) { + File parent = keystoreFile.getParentFile(); + if (parent != null && !parent.exists()) { + if (autoCreateKeystoreDir) { + boolean ok = parent.mkdirs(); + if (!ok && !parent.exists()) { + throw new IllegalStateException("Failed to create keystore parent directory: " + parent.getAbsolutePath()); + } + logger.info("Created keystore parent directory: {}", parent.getAbsolutePath()); + } else { + throw new IllegalStateException( + "Keystore parent directory does not exist: " + parent.getAbsolutePath() + + " (set lets-encrypt-helper.create-parent-dirs=true to auto-create)"); + } + } + } private TargetProtocol createObservableProtocol(AbstractHttp11Protocol protocol) { var observe = new TargetProtocol(sslHostConfig, protocol); From 9313f83bee6a9056b1a74f0ecf2a065014449e4b Mon Sep 17 00:00:00 2001 From: Steve Oberst <58824185+SteveOberst@users.noreply.github.com> Date: Tue, 19 Aug 2025 17:05:21 +0200 Subject: [PATCH 2/2] Add Unit-Tests for auto-creation of keystore parent dirs --- .../jetty/JettyKeystoreDirCreationTest.java | 125 ++++++++++++++++++ .../tomcat/TomcatKeystoreDirCreationTest.java | 124 +++++++++++++++++ 2 files changed, 249 insertions(+) create mode 100644 jetty/src/test/java/com/github/valb3r/letsencrypthelper/jetty/JettyKeystoreDirCreationTest.java create mode 100644 tomcat/src/test/java/com/github/valb3r/letsencrypthelper/tomcat/TomcatKeystoreDirCreationTest.java diff --git a/jetty/src/test/java/com/github/valb3r/letsencrypthelper/jetty/JettyKeystoreDirCreationTest.java b/jetty/src/test/java/com/github/valb3r/letsencrypthelper/jetty/JettyKeystoreDirCreationTest.java new file mode 100644 index 0000000..734ebe2 --- /dev/null +++ b/jetty/src/test/java/com/github/valb3r/letsencrypthelper/jetty/JettyKeystoreDirCreationTest.java @@ -0,0 +1,125 @@ +package com.github.valb3r.letsencrypthelper.jetty; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.web.ServerProperties; + +import java.io.File; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class JettyKeystoreDirCreationTest { + + @Test + public void createsParentDirWhenAutoCreateEnabled() throws Exception { + Path tmp = Path.of(System.getProperty("java.io.tmpdir")); + Path base = tmp.resolve("letsencrypt-helper-test-jetty").resolve("nested"); + Files.deleteIfExists(base.resolve("keystore.p12")); + // ensure parent does not exist + if (Files.exists(base)) { + Files.walk(base).map(Path::toFile).forEach(File::delete); + } + + String keystorePath = "file:" + base.resolve("keystore.p12").toAbsolutePath().toString(); + + ServerProperties props = new ServerProperties(); + // Find setSsl method to determine the SSL parameter type dynamically + java.lang.reflect.Method setSsl = null; + for (var m : ServerProperties.class.getMethods()) { + if (m.getName().equals("setSsl") && m.getParameterCount() == 1) { + setSsl = m; + break; + } + } + if (setSsl == null) throw new IllegalStateException("ServerProperties.setSsl method not found"); + Class sslClass = setSsl.getParameterTypes()[0]; + Object ssl = sslClass.getDeclaredConstructor().newInstance(); + var setKeyStore = sslClass.getMethod("setKeyStore", String.class); + var setKeyStorePassword = sslClass.getMethod("setKeyStorePassword", String.class); + var setKeyAlias = sslClass.getMethod("setKeyAlias", String.class); + var setKeyStoreType = sslClass.getMethod("setKeyStoreType", String.class); + setKeyStore.invoke(ssl, keystorePath); + setKeyStorePassword.invoke(ssl, "changeit"); + setKeyAlias.invoke(ssl, "alias"); + setKeyStoreType.invoke(ssl, "PKCS12"); + setSsl.invoke(props, ssl); + + var cfg = new JettyWellKnownLetsEncryptChallengeEndpointConfig( + props, 443, "example.com", "mailto:test@example.com", + "acct", "acme://letsencrypt.org", 1024, + Duration.ofDays(30), Duration.ofMinutes(1), Duration.ofDays(3650), true, true, true, 80, true + ); + + Method m = JettyWellKnownLetsEncryptChallengeEndpointConfig.class.getDeclaredMethod("createBasicKeystoreIfMissing"); + m.setAccessible(true); + + try { + m.invoke(cfg); + + assertThat(Files.exists(base)).isTrue(); + assertThat(Files.exists(base.resolve("keystore.p12"))).isTrue(); + } finally { + // cleanup + Files.deleteIfExists(base.resolve("keystore.p12")); + if (Files.exists(base)) { + Files.deleteIfExists(base); + Path parent = base.getParent(); + if (parent != null && Files.exists(parent)) { + Files.deleteIfExists(parent); + } + } + } + } + + @Test + public void failsWhenAutoCreateDisabled() throws Exception { + Path tmp = Path.of(System.getProperty("java.io.tmpdir")); + Path base = tmp.resolve("letsencrypt-helper-test-jetty").resolve("nested-no-create"); + Files.deleteIfExists(base.resolve("keystore.p12")); + if (Files.exists(base)) { + Files.walk(base).map(Path::toFile).forEach(File::delete); + } + + String keystorePath = "file:" + base.resolve("keystore.p12").toAbsolutePath().toString(); + + ServerProperties props = new ServerProperties(); + java.lang.reflect.Method setSsl2 = null; + for (var m : ServerProperties.class.getMethods()) { + if (m.getName().equals("setSsl") && m.getParameterCount() == 1) { + setSsl2 = m; + break; + } + } + if (setSsl2 == null) throw new IllegalStateException("ServerProperties.setSsl method not found"); + Class sslClass2 = setSsl2.getParameterTypes()[0]; + Object ssl2 = sslClass2.getDeclaredConstructor().newInstance(); + var setKeyStore2 = sslClass2.getMethod("setKeyStore", String.class); + var setKeyStorePassword2 = sslClass2.getMethod("setKeyStorePassword", String.class); + var setKeyAlias2 = sslClass2.getMethod("setKeyAlias", String.class); + var setKeyStoreType2 = sslClass2.getMethod("setKeyStoreType", String.class); + setKeyStore2.invoke(ssl2, keystorePath); + setKeyStorePassword2.invoke(ssl2, "changeit"); + setKeyAlias2.invoke(ssl2, "alias"); + setKeyStoreType2.invoke(ssl2, "PKCS12"); + setSsl2.invoke(props, ssl2); + + var cfg = new JettyWellKnownLetsEncryptChallengeEndpointConfig( + props, 443, "example.com", "mailto:test@example.com", + "acct", "acme://letsencrypt.org", 1024, + Duration.ofDays(30), Duration.ofMinutes(1), Duration.ofDays(3650), true, true, true, 80, false + ); + + Method m = JettyWellKnownLetsEncryptChallengeEndpointConfig.class.getDeclaredMethod("createBasicKeystoreIfMissing"); + m.setAccessible(true); + + assertThatThrownBy(() -> m.invoke(cfg)) + .satisfies(t -> { + assertThat(t.getCause()).isInstanceOf(IllegalStateException.class); + assertThat(t.getCause().getMessage()).contains("Keystore parent directory does not exist"); + }); + } +} diff --git a/tomcat/src/test/java/com/github/valb3r/letsencrypthelper/tomcat/TomcatKeystoreDirCreationTest.java b/tomcat/src/test/java/com/github/valb3r/letsencrypthelper/tomcat/TomcatKeystoreDirCreationTest.java new file mode 100644 index 0000000..5fbcd26 --- /dev/null +++ b/tomcat/src/test/java/com/github/valb3r/letsencrypthelper/tomcat/TomcatKeystoreDirCreationTest.java @@ -0,0 +1,124 @@ +package com.github.valb3r.letsencrypthelper.tomcat; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.web.ServerProperties; + +import java.io.File; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class TomcatKeystoreDirCreationTest { + + @Test + public void createsParentDirWhenAutoCreateEnabled() throws Exception { + Path tmp = Path.of(System.getProperty("java.io.tmpdir")); + Path base = tmp.resolve("letsencrypt-helper-test-tomcat").resolve("nested"); + Files.deleteIfExists(base.resolve("keystore.p12")); + // ensure parent does not exist + if (Files.exists(base)) { + Files.walk(base).map(Path::toFile).forEach(File::delete); + } + + String keystorePath = "file:" + base.resolve("keystore.p12").toAbsolutePath().toString(); + + ServerProperties props = new ServerProperties(); + java.lang.reflect.Method setSsl = null; + for (var m : ServerProperties.class.getMethods()) { + if (m.getName().equals("setSsl") && m.getParameterCount() == 1) { + setSsl = m; + break; + } + } + if (setSsl == null) throw new IllegalStateException("ServerProperties.setSsl method not found"); + Class sslClass = setSsl.getParameterTypes()[0]; + Object ssl = sslClass.getDeclaredConstructor().newInstance(); + var setKeyStore = sslClass.getMethod("setKeyStore", String.class); + var setKeyStorePassword = sslClass.getMethod("setKeyStorePassword", String.class); + var setKeyAlias = sslClass.getMethod("setKeyAlias", String.class); + var setKeyStoreType = sslClass.getMethod("setKeyStoreType", String.class); + setKeyStore.invoke(ssl, keystorePath); + setKeyStorePassword.invoke(ssl, "changeit"); + setKeyAlias.invoke(ssl, "alias"); + setKeyStoreType.invoke(ssl, "PKCS12"); + setSsl.invoke(props, ssl); + + var cfg = new TomcatWellKnownLetsEncryptChallengeEndpointConfig( + props, 443, "example.com", "mailto:test@example.com", + "acct", "acme://letsencrypt.org", 1024, + Duration.ofDays(30), Duration.ofMinutes(1), Duration.ofDays(3650), true, true, true, 80, true + ); + + Method m = TomcatWellKnownLetsEncryptChallengeEndpointConfig.class.getDeclaredMethod("createBasicKeystoreIfMissing"); + m.setAccessible(true); + + try { + m.invoke(cfg); + + assertThat(Files.exists(base)).isTrue(); + assertThat(Files.exists(base.resolve("keystore.p12"))).isTrue(); + } finally { + // cleanup + Files.deleteIfExists(base.resolve("keystore.p12")); + if (Files.exists(base)) { + Files.deleteIfExists(base); + Path parent = base.getParent(); + if (parent != null && Files.exists(parent)) { + Files.deleteIfExists(parent); + } + } + } + } + + @Test + public void failsWhenAutoCreateDisabled() throws Exception { + Path tmp = Path.of(System.getProperty("java.io.tmpdir")); + Path base = tmp.resolve("letsencrypt-helper-test-tomcat").resolve("nested-no-create"); + Files.deleteIfExists(base.resolve("keystore.p12")); + if (Files.exists(base)) { + Files.walk(base).map(Path::toFile).forEach(File::delete); + } + + String keystorePath = "file:" + base.resolve("keystore.p12").toAbsolutePath().toString(); + + ServerProperties props = new ServerProperties(); + java.lang.reflect.Method setSsl2 = null; + for (var m : ServerProperties.class.getMethods()) { + if (m.getName().equals("setSsl") && m.getParameterCount() == 1) { + setSsl2 = m; + break; + } + } + if (setSsl2 == null) throw new IllegalStateException("ServerProperties.setSsl method not found"); + Class sslClass2 = setSsl2.getParameterTypes()[0]; + Object ssl2 = sslClass2.getDeclaredConstructor().newInstance(); + var setKeyStore2 = sslClass2.getMethod("setKeyStore", String.class); + var setKeyStorePassword2 = sslClass2.getMethod("setKeyStorePassword", String.class); + var setKeyAlias2 = sslClass2.getMethod("setKeyAlias", String.class); + var setKeyStoreType2 = sslClass2.getMethod("setKeyStoreType", String.class); + setKeyStore2.invoke(ssl2, keystorePath); + setKeyStorePassword2.invoke(ssl2, "changeit"); + setKeyAlias2.invoke(ssl2, "alias"); + setKeyStoreType2.invoke(ssl2, "PKCS12"); + setSsl2.invoke(props, ssl2); + + var cfg = new TomcatWellKnownLetsEncryptChallengeEndpointConfig( + props, 443, "example.com", "mailto:test@example.com", + "acct", "acme://letsencrypt.org", 1024, + Duration.ofDays(30), Duration.ofMinutes(1), Duration.ofDays(3650), true, true, true, 80, false + ); + + Method m = TomcatWellKnownLetsEncryptChallengeEndpointConfig.class.getDeclaredMethod("createBasicKeystoreIfMissing"); + m.setAccessible(true); + + assertThatThrownBy(() -> m.invoke(cfg)) + .satisfies(t -> { + assertThat(t.getCause()).isInstanceOf(IllegalStateException.class); + assertThat(t.getCause().getMessage()).contains("Keystore parent directory does not exist"); + }); + } +}