diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 9c49e94d..ec8c7841 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,15 +1,22 @@
-image: maven:3-jdk-8
-
build:
+ tags:
+ - maven
+ - docker
script:
- - mvn -B clean compile
+ - mvn clean compile
test:
+ tags:
+ - maven
+ - docker
script:
- - mvn -B org.jacoco:jacoco-maven-plugin:prepare-agent verify -Dmaven.test.failure.ignore=true -DskipITs=false -DpebbleHost=pebble
+ - mvn -B -P ci org.jacoco:jacoco-maven-plugin:prepare-agent verify -Dmaven.test.failure.ignore=true
- mvn -B sonar:sonar -Dsonar.gitlab.commit_sha=$CI_BUILD_REF -Dsonar.gitlab.ref_name=$CI_BUILD_REF_NAME
deploy:
+ tags:
+ - maven
+ - docker
script:
- mvn -B install site:site
diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/it/OrderIT.java b/acme4j-client/src/test/java/org/shredzone/acme4j/it/OrderIT.java
deleted file mode 100644
index 32ebd114..00000000
--- a/acme4j-client/src/test/java/org/shredzone/acme4j/it/OrderIT.java
+++ /dev/null
@@ -1,71 +0,0 @@
-/*
- * acme4j - Java ACME client
- *
- * Copyright (C) 2017 Richard "Shred" Körber
- * http://acme4j.shredzone.org
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
- */
-package org.shredzone.acme4j.it;
-
-import static org.hamcrest.Matchers.*;
-import static org.junit.Assert.assertThat;
-
-import java.io.IOException;
-import java.security.KeyPair;
-import java.time.Duration;
-import java.time.Instant;
-
-import org.junit.Test;
-import org.shredzone.acme4j.Authorization;
-import org.shredzone.acme4j.Order;
-import org.shredzone.acme4j.Registration;
-import org.shredzone.acme4j.RegistrationBuilder;
-import org.shredzone.acme4j.Session;
-import org.shredzone.acme4j.Status;
-import org.shredzone.acme4j.challenge.Http01Challenge;
-import org.shredzone.acme4j.exception.AcmeException;
-import org.shredzone.acme4j.util.TestUtils;
-
-/**
- * Tests the complete process of ordering a certificate.
- */
-public class OrderIT extends AbstractPebbleIT {
-
- @Test
- public void testOrder() throws AcmeException, IOException {
- KeyPair keyPair = createKeyPair();
- Session session = new Session(pebbleURI(), keyPair);
-
- Registration reg = new RegistrationBuilder().agreeToTermsOfService().create(session);
-
- byte[] csr = TestUtils.getResourceAsByteArray("/csr.der");
- Instant notBefore = Instant.now();
- Instant notAfter = notBefore.plus(Duration.ofDays(20L));
-
- Order order = reg.orderCertificate(csr, notBefore, notAfter);
- assertThat(order.getCsr(), is(csr));
- assertThat(order.getNotBefore(), is(notBefore));
- assertThat(order.getNotAfter(), is(notAfter));
- assertThat(order.getStatus(), is(Status.PENDING));
-
- for (Authorization auth : order.getAuthorizations()) {
- processAuthorization(auth);
- }
- }
-
- private void processAuthorization(Authorization auth) throws AcmeException {
- assertThat(auth.getDomain(), is("example.com"));
- if (auth.getStatus() == Status.PENDING) {
- Http01Challenge challenge = auth.findChallenge(Http01Challenge.TYPE);
- assertThat(challenge, is(notNullValue()));
- challenge.trigger();
- }
- }
-
-}
diff --git a/acme4j-it/.project b/acme4j-it/.project
new file mode 100644
index 00000000..7789f73f
--- /dev/null
+++ b/acme4j-it/.project
@@ -0,0 +1,23 @@
+
+
+ * This server can be used to validate {@code dns-01} challenges.
+ */
+public class DnsServer {
+ private static final Logger LOG = LoggerFactory.getLogger(DnsServer.class);
+ private static final int UDP_SIZE = 512;
+ private static final long TTL = 86321L;
+
+ private final Map
+ * This server can be used to validate {@code http-01} challenges.
+ */
+public class HttpServer {
+ private static final Logger LOG = LoggerFactory.getLogger(HttpServer.class);
+
+ private static final String TOKEN_PATH = "/.well-known/acme-challenge/";
+ private static final Pattern TOKEN_PATTERN = Pattern.compile("^" + Pattern.quote(TOKEN_PATH) + "([^/]+)$");
+
+ private final Map
+ * This server can be used to validate {@code tls-sni-02} challenges.
+ */
+public class TlsSniServer {
+ private static final Logger LOG = LoggerFactory.getLogger(TlsSniServer.class);
+ private static final char[] PASSWORD = "shibboleet".toCharArray();
+
+ private KeyStore keyStore = null;
+ private Thread thread = null;
+ private volatile boolean running = false;
+ private volatile boolean listening = false;
+
+ /**
+ * Adds a certificate to the set of known certificates.
+ *
+ * The certificate's CN and SANs are used for SNI.
+ *
+ * @param alias
+ * Internal alias
+ * @param privateKey
+ * Private key to be used with this certificate
+ * @param cert
+ * {@link X509Certificate} to be added
+ */
+ public void addCertificate(String alias, PrivateKey privateKey, X509Certificate cert) {
+ initKeyStore();
+
+ try {
+ keyStore.setKeyEntry(alias, privateKey, PASSWORD, new Certificate[] {cert});
+ } catch (KeyStoreException ex) {
+ throw new IllegalArgumentException("Failed to add certificate " + alias, ex);
+ }
+ }
+
+ /**
+ * Removes a certificate.
+ *
+ * @param alias
+ * Internal alias of the certificate to remove
+ */
+ public void removeCertificate(String alias) {
+ initKeyStore();
+
+ try {
+ keyStore.deleteEntry(alias);
+ } catch (KeyStoreException ex) {
+ throw new IllegalArgumentException("Failed to remove certificate " + alias, ex);
+ }
+ }
+
+ /**
+ * Starts the TlsSni server.
+ *
+ * @param port
+ * Port to listen to
+ */
+ public void start(int port) {
+ if (thread != null) {
+ throw new IllegalStateException("Server is already running");
+ }
+
+ running = true;
+ thread = new Thread(() -> serve(port));
+ thread.setName("tls-sni server");
+ thread.start();
+ LOG.info("tls-sni server listening at port {}", port);
+ }
+
+ /**
+ * Stops the TlsSni server.
+ */
+ public void stop() {
+ if (thread != null) {
+ running = false;
+ thread.interrupt();
+ thread = null;
+ }
+ }
+
+ /**
+ * Checks if the server was started up and is listening to connections.
+ */
+ public boolean isListening() {
+ return listening;
+ }
+
+ /**
+ * Opens an SSL server socket and processes incoming requests.
+ *
+ * @param port
+ * Port to listen at
+ */
+ private void serve(int port) {
+ SSLContext sslContext = createSSLContext();
+ SSLServerSocketFactory sslServerSocketFactory = sslContext.getServerSocketFactory();
+
+ while (running) {
+ try (SSLServerSocket sslServerSocket = (SSLServerSocket)
+ sslServerSocketFactory.createServerSocket(port)){
+ listening = true;
+ process(sslServerSocket);
+ } catch (Exception ex) {
+ LOG.error("Failed to process query", ex);
+ }
+ }
+
+ listening = false;
+ }
+
+ /**
+ * Accept and process an incoming request. Only the TLS handshake is used here.
+ * Incoming data is just consumed, and the socket is closed after that.
+ *
+ * @param sslServerSocket
+ * {@link SSLServerSocket} to accept connections from
+ * @throws IOException
+ * if the request could not be processed
+ */
+ private void process(SSLServerSocket sslServerSocket) throws IOException {
+ try (SSLSocket sslSocket = (SSLSocket) sslServerSocket.accept()) {
+ sslSocket.setEnabledCipherSuites(sslSocket.getSupportedCipherSuites());
+ sslSocket.startHandshake();
+
+ SSLSession sslSession = sslSocket.getSession();
+ X509Certificate cert = (X509Certificate) sslSession.getLocalCertificates()[0];
+ LOG.info("tls-sni: {}", domainsToString(cert));
+
+ try (InputStream in = sslSocket.getInputStream()) {
+ while (in.read() >= 0);
+ }
+ }
+ }
+
+ /**
+ * Lazily initializes the {@link KeyStore} instance to be used. The key store is empty
+ * after initialization.
+ */
+ private void initKeyStore() {
+ if (keyStore == null) {
+ try {
+ keyStore = KeyStore.getInstance("JKS");
+ keyStore.load(null, null);
+ } catch (KeyStoreException | NoSuchAlgorithmException | CertificateException
+ | IOException ex) {
+ throw new IllegalStateException("Failed to create key store", ex);
+ }
+ }
+ }
+
+ /**
+ * Creates a {@link SSLContext} that uses the internal {@link KeyStore} for key and
+ * trust management.
+ *
+ * @return {@link SSLContext} instance
+ */
+ private SSLContext createSSLContext() {
+ initKeyStore();
+
+ try {
+ KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance("NewSunX509");
+ keyManagerFactory.init(keyStore, PASSWORD);
+ KeyManager[] km = keyManagerFactory.getKeyManagers();
+
+ TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance("SunX509");
+ trustManagerFactory.init(keyStore);
+ TrustManager[] tm = trustManagerFactory.getTrustManagers();
+
+ SSLContext sslContext = SSLContext.getInstance("TLSv1");
+ sslContext.init(km, tm, null);
+
+ return sslContext;
+ } catch (KeyManagementException | NoSuchAlgorithmException | KeyStoreException
+ | UnrecoverableKeyException ex) {
+ throw new IllegalStateException("Could not create SSLContext", ex);
+ }
+ }
+
+ /**
+ * Extracts all SANs of the given certificate and returns them as a string.
+ *
+ * @param cert
+ * {@link X509Certificate} to read the SANs from
+ * @return String of all SAN names joined together and separated by comma
+ */
+ private String domainsToString(X509Certificate cert) {
+ try {
+ return cert.getSubjectAlternativeNames().stream()
+ .filter(c -> ((Number) c.get(0)).intValue() == GeneralName.dNSName)
+ .map(c -> (String) c.get(1))
+ .collect(Collectors.joining(", "));
+ } catch (CertificateParsingException ex) {
+ throw new IllegalArgumentException("bad certificate", ex);
+ }
+ }
+
+}
diff --git a/acme4j-it/src/main/resources/.gitignore b/acme4j-it/src/main/resources/.gitignore
new file mode 100644
index 00000000..e69de29b
diff --git a/acme4j-it/src/site/markdown/index.md.vm b/acme4j-it/src/site/markdown/index.md.vm
new file mode 100644
index 00000000..7236cc8f
--- /dev/null
+++ b/acme4j-it/src/site/markdown/index.md.vm
@@ -0,0 +1,56 @@
+acme4j Integration Tests
+========================
+
+This module contains an Integration Test of _acme4j_.
+
+It builds a [Pebble](https://github.com/letsencrypt/pebble) docker image and runs it. After that, a number of integration tests are performed.
+
+How to Use
+----------
+
+Integration tests are disabled by default, to ensure that _acme4j_ can be build on systems with not much more than a _maven_ installation. For running the integration tests, _Docker_ must be available.
+
+To enable the integration tests, use the `ci` profile when building the _acme4j_ project:
+
+```
+mvn -P ci clean install
+```
+
+It will build and run a Pebble server, perform the integration tests, and stop the Pebble server after that.
+
+The Pebble server needs to connect to servers that are provided by the maven integration tests. For this reason, the Pebble server must run on the same machine where maven is running, so the servers are available via `localhost`.
+
+Starting Pebble manually
+------------------------
+
+You can also run Pebble on your machine, to run the integration tests inside your IDE.
+
+To do so, change to the `acme4j-it` module, then run `docker:build` to download and build the Pebble image, and `docker:start` to start the Pebble server:
+
+```
+cd acme4j-it
+mvn docker:build
+mvn docker:start
+```
+
+To stop the server:
+
+```
+mvn docker:stop
+```
+
+GitLab CI
+---------
+
+_acme4j_ contains a GitLab CI configuration file.
+
+The CI runner should be set up with a `shell` executor. Maven and Docker should be installed on the CI runner, and the shell executor user should be able to use both.
+
+The tags `maven` and `docker` are used to select the executor.
+
+`acme4j-it` API
+---------------
+
+The `acme4j-it` module provides test servers for the `http-01`, `dns-01` and `tls-sni-02` challenges. You can use these classes for your own projects. However, they are not part of the official _acme4j_ API and subject to change without notice.
+
+Note that these servers are very simple implementations without any security measures. They are tailor-made for integration tests. Do not use them in production code!
diff --git a/acme4j-it/src/site/site.xml b/acme4j-it/src/site/site.xml
new file mode 100644
index 00000000..81208233
--- /dev/null
+++ b/acme4j-it/src/site/site.xml
@@ -0,0 +1,42 @@
+
+
+
* These tests require a running
* Pebble ACME test server at
- * localhost port 14000.
+ * localhost port 14000. The host and port can be changed via the system property
+ * {@code pebbleHost} and {@code pebblePort} respectively.
*/
-public abstract class AbstractPebbleIT {
+public abstract class PebbleITBase {
private final String pebbleHost = System.getProperty("pebbleHost", "localhost");
private final int pebblePort = Integer.parseInt(System.getProperty("pebblePort", "14000"));
@@ -47,13 +48,7 @@ public abstract class AbstractPebbleIT {
* @return Created {@link KeyPair}, guaranteed to be unknown to the Pebble server
*/
protected KeyPair createKeyPair() {
- try {
- KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
- keyGen.initialize(2048);
- return keyGen.generateKeyPair();
- } catch (NoSuchAlgorithmException ex) {
- throw new InternalError(ex);
- }
+ return KeyPairUtils.createKeyPair(2048);
}
/**
diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/it/RegistrationIT.java b/acme4j-it/src/test/java/org/shredzone/acme4j/it/RegistrationIT.java
similarity index 89%
rename from acme4j-client/src/test/java/org/shredzone/acme4j/it/RegistrationIT.java
rename to acme4j-it/src/test/java/org/shredzone/acme4j/it/RegistrationIT.java
index 0090d2f5..8681550d 100644
--- a/acme4j-client/src/test/java/org/shredzone/acme4j/it/RegistrationIT.java
+++ b/acme4j-it/src/test/java/org/shredzone/acme4j/it/RegistrationIT.java
@@ -32,7 +32,7 @@ import org.shredzone.acme4j.exception.AcmeUnauthorizedException;
/**
* Registration related integration tests.
*/
-public class RegistrationIT extends AbstractPebbleIT {
+public class RegistrationIT extends PebbleITBase {
@Test
public void testCreate() throws AcmeException {
@@ -55,16 +55,14 @@ public class RegistrationIT extends AbstractPebbleIT {
// assertThat(reg.getStatus(), is(Status.GOOD));
assertThat(reg.getTermsOfServiceAgreed(), is(true));
- // TODO PEBBLE: missing
- /*
// Bind another Registration object
- Session session2 = new Session(pebbleURI(), keyPair);
- Registration reg2 = Registration.bind(session2, location);
- assertThat(reg2.getLocation(), is(location));
- assertThat(reg2.getContacts(), contains(URI.create("mailto:acme@example.com")));
- assertThat(reg2.getStatus(), is(Status.GOOD));
- assertThat(reg2.getTermsOfServiceAgreed(), is(true));
- */
+ // TODO PEBBLE: Not supported yet
+ // Session session2 = new Session(pebbleURI(), keyPair);
+ // Registration reg2 = Registration.bind(session2, location);
+ // assertThat(reg2.getLocation(), is(location));
+ // assertThat(reg2.getContacts(), contains(URI.create("mailto:acme@example.com")));
+ // assertThat(reg2.getStatus(), is(Status.GOOD));
+ // assertThat(reg2.getTermsOfServiceAgreed(), is(true));
}
@Test
diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/it/SessionIT.java b/acme4j-it/src/test/java/org/shredzone/acme4j/it/SessionIT.java
similarity index 91%
rename from acme4j-client/src/test/java/org/shredzone/acme4j/it/SessionIT.java
rename to acme4j-it/src/test/java/org/shredzone/acme4j/it/SessionIT.java
index 4278bc1c..6e416b19 100644
--- a/acme4j-client/src/test/java/org/shredzone/acme4j/it/SessionIT.java
+++ b/acme4j-it/src/test/java/org/shredzone/acme4j/it/SessionIT.java
@@ -29,7 +29,7 @@ import org.shredzone.acme4j.exception.AcmeException;
/**
* Session related integration tests.
*/
-public class SessionIT extends AbstractPebbleIT {
+public class SessionIT extends PebbleITBase {
private final KeyPair keyPair = createKeyPair();
@@ -52,9 +52,6 @@ public class SessionIT extends AbstractPebbleIT {
public void testResources() throws AcmeException {
Session session = new Session(pebbleURI(), keyPair);
- // TODO: Not yet supported by Pebble
- // assertIsPebbleUrl(session.resourceUrl(Resource.KEY_CHANGE));
-
assertIsPebbleUrl(session.resourceUrl(Resource.NEW_NONCE));
assertIsPebbleUrl(session.resourceUrl(Resource.NEW_REG));
assertIsPebbleUrl(session.resourceUrl(Resource.NEW_ORDER));
@@ -67,7 +64,6 @@ public class SessionIT extends AbstractPebbleIT {
Metadata meta = session.getMetadata();
assertThat(meta, not(nullValue()));
- // Pebble does not currently deliver any metadata
assertThat(meta.getTermsOfService(), is(URI.create("data:text/plain,Do%20what%20thou%20wilt")));
assertThat(meta.getWebsite(), is(nullValue()));
assertThat(meta.getCaaIdentities(), is(empty()));
diff --git a/acme4j-it/src/test/pebble/pebble-config.json b/acme4j-it/src/test/pebble/pebble-config.json
new file mode 100644
index 00000000..6f51b3d4
--- /dev/null
+++ b/acme4j-it/src/test/pebble/pebble-config.json
@@ -0,0 +1,7 @@
+{
+ "pebble": {
+ "listenAddress": "0.0.0.0:14000",
+ "httpPort": 5002,
+ "tlsPort": 5001
+ }
+}
diff --git a/acme4j-it/src/test/resources/simplelogger.properties b/acme4j-it/src/test/resources/simplelogger.properties
new file mode 100644
index 00000000..9d089390
--- /dev/null
+++ b/acme4j-it/src/test/resources/simplelogger.properties
@@ -0,0 +1,2 @@
+
+org.slf4j.simpleLogger.log.org.shredzone.acme4j = debug
diff --git a/pom.xml b/pom.xml
index 3b13d267..ec48e1e4 100644
--- a/pom.xml
+++ b/pom.xml
@@ -52,16 +52,19 @@