diff --git a/gradle/changelog/docker_healthcheck.yaml b/gradle/changelog/docker_healthcheck.yaml
new file mode 100644
index 0000000000..86a521c176
--- /dev/null
+++ b/gradle/changelog/docker_healthcheck.yaml
@@ -0,0 +1,2 @@
+- type: fixed
+ description: Fix docker healthcheck for custom ports, https and forced base url ([#2110](https://github.com/scm-manager/scm-manager/pull/2110))
diff --git a/scm-packaging/docker/Dockerfile.alpine b/scm-packaging/docker/Dockerfile.alpine
index be2adebfc0..97cff39f8d 100644
--- a/scm-packaging/docker/Dockerfile.alpine
+++ b/scm-packaging/docker/Dockerfile.alpine
@@ -69,6 +69,6 @@ EXPOSE 8080
# we us a high relative high start period,
# because the start time depends on the number of installed plugins
HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \
- CMD wget --no-verbose --tries=1 --spider http://localhost:8080/scm/api/v2 || exit 1
+ CMD /opt/scm-server/bin/healthcheck || exit 1
ENTRYPOINT [ "/opt/scm-server/bin/scm-server" ]
diff --git a/scm-packaging/docker/Dockerfile.debian b/scm-packaging/docker/Dockerfile.debian
index a655c169dd..00d29decb5 100644
--- a/scm-packaging/docker/Dockerfile.debian
+++ b/scm-packaging/docker/Dockerfile.debian
@@ -50,7 +50,7 @@ COPY build/docker/opt /opt
RUN set -x \
&& apt-get update \
# libfreetype6 libfontconfig1 graphviz
- && apt-get install -y --no-install-recommends libfreetype6 libfontconfig1 graphviz mercurial bash ca-certificates wget \
+ && apt-get install -y --no-install-recommends libfreetype6 libfontconfig1 graphviz mercurial bash ca-certificates \
# use gid 0 for openshift compatibility
&& useradd -d "${SCM_HOME}" -u 1000 -g 0 -m -s /bin/bash scm \
&& mkdir -p ${SCM_HOME} ${CACHE_DIR} \
@@ -68,6 +68,6 @@ EXPOSE 8080
# we us a high relative high start period,
# because the start time depends on the number of installed plugins
HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \
- CMD wget --no-verbose --tries=1 --spider http://localhost:8080/scm/api/v2 || exit 1
+ CMD /opt/scm-server/bin/healthcheck || exit 1
ENTRYPOINT [ "/opt/scm-server/bin/scm-server" ]
diff --git a/scm-packaging/docker/build.gradle b/scm-packaging/docker/build.gradle
index a4c8cdbfca..a164a50b32 100644
--- a/scm-packaging/docker/build.gradle
+++ b/scm-packaging/docker/build.gradle
@@ -90,7 +90,7 @@ task setupBuilder() {
}
task build(type: Exec) {
- commandLine = ["docker", "buildx", "bake", "--builder", "scm-builder", isSnapshot ? "dev": "prod"]
+ commandLine = ["docker", "buildx", "bake", "--builder", "scm-builder", isSnapshot ? "dev": "prod", isSnapshot ? "--load" : ""]
environment "VERSION", dockerTag
environment "COMMIT_SHA", revision
environment "IMAGE", dockerRepository
@@ -122,7 +122,7 @@ task publish() {
}
def inspect = new JsonSlurper().parseText(stdout.toString())
def manifest = inspect.manifests.find { m -> m.platform.architecture == "arm" }
-
+
// append arm image to manifest with version and without os suffix
exec {
commandLine = ["docker", "buildx", "imagetools", "create", "--append", "-t", "${dockerRepository}:${dockerTag}", "${dockerRepository}:${dockerTag}-debian@${manifest.digest}"]
diff --git a/scm-packaging/docker/src/main/fs/etc/scm/server-config.xml b/scm-packaging/docker/src/main/fs/etc/scm/server-config.xml
index 6a16453b6e..d7c0e6c6cb 100644
--- a/scm-packaging/docker/src/main/fs/etc/scm/server-config.xml
+++ b/scm-packaging/docker/src/main/fs/etc/scm/server-config.xml
@@ -65,10 +65,10 @@
-
+
-
+
diff --git a/scm-packaging/docker/src/main/fs/opt/scm-server/bin/healthcheck b/scm-packaging/docker/src/main/fs/opt/scm-server/bin/healthcheck
new file mode 100755
index 0000000000..bc19368b52
--- /dev/null
+++ b/scm-packaging/docker/src/main/fs/opt/scm-server/bin/healthcheck
@@ -0,0 +1,6 @@
+#!/bin/sh
+exec java -cp "/etc/scm:/opt/scm-server/lib/*" \
+ -client -Xmx64m \
+ -Djava.awt.headless=true \
+ -Dlogback.configurationFile=logging.xml \
+ sonia.scm.server.HealthCheck
diff --git a/scm-server/build.gradle b/scm-server/build.gradle
index 11dad31b0f..445ac68913 100644
--- a/scm-server/build.gradle
+++ b/scm-server/build.gradle
@@ -35,4 +35,13 @@ dependencies {
implementation libraries.jettyWebapp
// TODO do we need jetty jmx?
implementation libraries.jettyJmx
+
+ // tests
+ testImplementation libraries.junitJupiterApi
+ testImplementation libraries.junitJupiterParams
+ testRuntimeOnly libraries.junitJupiterEngine
+ testImplementation libraries.junitPioneer
+ testImplementation libraries.assertj
+
+ testImplementation libraries.guava
}
diff --git a/scm-server/gradle.lockfile b/scm-server/gradle.lockfile
index 05a836457a..fe28779a44 100644
--- a/scm-server/gradle.lockfile
+++ b/scm-server/gradle.lockfile
@@ -1,8 +1,17 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
+com.google.code.findbugs:jsr305:3.0.2=testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy
+com.google.errorprone:error_prone_annotations:2.3.4=testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy
+com.google.guava:failureaccess:1.0.1=testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy
+com.google.guava:guava:30.1-jre=testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy
+com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy
+com.google.j2objc:j2objc-annotations:1.3=testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy
commons-daemon:commons-daemon:1.2.3=compileClasspath,compileClasspathCopy,default,defaultCopy,runtimeClasspath,runtimeClasspathCopy,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy
javax.servlet:javax.servlet-api:3.1.0=compileClasspath,compileClasspathCopy,default,defaultCopy,runtimeClasspath,runtimeClasspathCopy,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy
+org.apiguardian:apiguardian-api:1.1.0=testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy
+org.assertj:assertj-core:3.18.1=testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy
+org.checkerframework:checker-qual:3.5.0=testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy
org.eclipse.jetty:jetty-http:9.4.44.v20210927=compileClasspath,compileClasspathCopy,default,defaultCopy,runtimeClasspath,runtimeClasspathCopy,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy
org.eclipse.jetty:jetty-io:9.4.44.v20210927=compileClasspath,compileClasspathCopy,default,defaultCopy,runtimeClasspath,runtimeClasspathCopy,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy
org.eclipse.jetty:jetty-jmx:9.4.44.v20210927=compileClasspath,compileClasspathCopy,default,defaultCopy,runtimeClasspath,runtimeClasspathCopy,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy
@@ -17,6 +26,19 @@ org.jacoco:org.jacoco.agent:0.8.7=jacocoAgentCopy,jacocoAntCopy
org.jacoco:org.jacoco.ant:0.8.7=jacocoAntCopy
org.jacoco:org.jacoco.core:0.8.7=jacocoAntCopy
org.jacoco:org.jacoco.report:0.8.7=jacocoAntCopy
+org.junit-pioneer:junit-pioneer:1.6.2=testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy
+org.junit.jupiter:junit-jupiter-api:5.7.0=testCompileClasspath,testCompileClasspathCopy
+org.junit.jupiter:junit-jupiter-api:5.7.2=testRuntimeClasspath,testRuntimeClasspathCopy
+org.junit.jupiter:junit-jupiter-engine:5.7.2=testRuntimeClasspath,testRuntimeClasspathCopy
+org.junit.jupiter:junit-jupiter-params:5.7.0=testCompileClasspath,testCompileClasspathCopy
+org.junit.jupiter:junit-jupiter-params:5.7.2=testRuntimeClasspath,testRuntimeClasspathCopy
+org.junit.platform:junit-platform-commons:1.7.0=testCompileClasspath,testCompileClasspathCopy
+org.junit.platform:junit-platform-commons:1.7.2=testRuntimeClasspath,testRuntimeClasspathCopy
+org.junit.platform:junit-platform-engine:1.7.2=testRuntimeClasspath,testRuntimeClasspathCopy
+org.junit.platform:junit-platform-launcher:1.7.2=testRuntimeClasspath,testRuntimeClasspathCopy
+org.junit:junit-bom:5.7.0=testCompileClasspath,testCompileClasspathCopy
+org.junit:junit-bom:5.7.2=testRuntimeClasspath,testRuntimeClasspathCopy
+org.opentest4j:opentest4j:1.2.0=testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy
org.ow2.asm:asm-analysis:9.1=jacocoAntCopy
org.ow2.asm:asm-commons:9.1=jacocoAntCopy
org.ow2.asm:asm-tree:9.1=jacocoAntCopy
diff --git a/scm-server/src/main/java/sonia/scm/server/HealthCheck.java b/scm-server/src/main/java/sonia/scm/server/HealthCheck.java
new file mode 100644
index 0000000000..43b3734b49
--- /dev/null
+++ b/scm-server/src/main/java/sonia/scm/server/HealthCheck.java
@@ -0,0 +1,153 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.server;
+
+import javax.net.ssl.HttpsURLConnection;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLSocketFactory;
+import javax.net.ssl.X509TrustManager;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.security.KeyManagementException;
+import java.security.NoSuchAlgorithmException;
+import java.security.cert.X509Certificate;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.Callable;
+
+public class HealthCheck implements Callable {
+
+ private final List listeners;
+
+ public HealthCheck(List configuration) {
+ this.listeners = configuration;
+ }
+
+ public HealthCheck(Listener... listeners) {
+ this.listeners = Arrays.asList(listeners);
+ }
+
+ public static void main(String[] args) {
+ HealthCheck check = new HealthCheck(new ServerConfiguration().getListeners());
+ Integer exitCode = check.call();
+ System.exit(exitCode);
+ }
+
+ @Override
+ public Integer call() {
+ return listeners.stream()
+ .map(l -> String.format("%s://127.0.0.1:%d%s/api/v2", l.getScheme(), l.getPort(), contextPath(l)))
+ .mapToInt(this::checkUrl)
+ .max()
+ .orElse(0);
+ }
+
+ private String contextPath(Listener listener) {
+ if ("/".equals(listener.getContextPath())) {
+ return "";
+ }
+ return listener.getContextPath();
+ }
+
+ private Integer checkUrl(String url) {
+ return checkUrl(url, true);
+ }
+
+ private int checkUrl(String url, boolean followRedirect) {
+ try {
+ HttpURLConnection connection = createConnection(url);
+ int code = connection.getResponseCode();
+ if (isRedirect(code) && followRedirect) {
+ String location = connection.getHeaderField("Location");
+ if (location != null && !location.isEmpty()) {
+ return checkUrl(location, false);
+ } else {
+ return 1;
+ }
+ }
+ return code == 200 ? 0 : 1;
+ } catch (IOException e) {
+ return 2;
+ }
+ }
+
+ private boolean isRedirect(int code) {
+ return code == HttpServletResponse.SC_MOVED_PERMANENTLY
+ || code == HttpServletResponse.SC_MOVED_TEMPORARILY // same as SC_FOUND
+ || code == HttpServletResponse.SC_SEE_OTHER
+ || code == HttpServletResponse.SC_TEMPORARY_REDIRECT
+ || code == 308; // could not find SC field
+ }
+
+ private HttpURLConnection createConnection(String url) throws IOException {
+ HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
+ connection.setConnectTimeout(5000);
+ connection.setReadTimeout(15000);
+ connection.setRequestProperty("User-Agent", "scm-health-check/1.0");
+ if (connection instanceof HttpsURLConnection) {
+ applyHttpsConfiguration((HttpsURLConnection) connection);
+ }
+ return connection;
+ }
+
+ @SuppressWarnings("java:S5527")
+ private void applyHttpsConfiguration(HttpsURLConnection connection) {
+ connection.setHostnameVerifier((hostname, session) -> true);
+ SSLSocketFactory socketFactory = createSslSocketFactory();
+ if (socketFactory != null) {
+ connection.setSSLSocketFactory(socketFactory);
+ }
+ }
+
+ private SSLSocketFactory createSslSocketFactory() {
+ try {
+ SSLContext context = SSLContext.getInstance("TLSv1.2");
+ context.init(null, new X509TrustManager[]{new TrustAllTrustManager()}, null);
+ return context.getSocketFactory();
+ } catch (NoSuchAlgorithmException | KeyManagementException e) {
+ return null;
+ }
+ }
+
+ @SuppressWarnings("java:S4830")
+ private static class TrustAllTrustManager implements X509TrustManager {
+ @Override
+ public void checkClientTrusted(X509Certificate[] chain, String authType) {
+ // accept anything
+ }
+
+ @Override
+ public void checkServerTrusted(X509Certificate[] chain, String authType) {
+ // accept anything
+ }
+
+ @Override
+ public X509Certificate[] getAcceptedIssuers() {
+ return new X509Certificate[0];
+ }
+ }
+}
diff --git a/scm-server/src/main/java/sonia/scm/server/Listener.java b/scm-server/src/main/java/sonia/scm/server/Listener.java
new file mode 100644
index 0000000000..dcf1307399
--- /dev/null
+++ b/scm-server/src/main/java/sonia/scm/server/Listener.java
@@ -0,0 +1,50 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.server;
+
+public class Listener {
+
+ private final String scheme;
+ private final int port;
+ private final String contextPath;
+
+ public Listener(String scheme, int port, String contextPath) {
+ this.scheme = scheme;
+ this.port = port;
+ this.contextPath = contextPath;
+ }
+
+ public String getScheme() {
+ return scheme;
+ }
+
+ public int getPort() {
+ return port;
+ }
+
+ public String getContextPath() {
+ return contextPath;
+ }
+}
diff --git a/scm-server/src/main/java/sonia/scm/server/ScmServer.java b/scm-server/src/main/java/sonia/scm/server/ScmServer.java
index c046f19818..054175da20 100644
--- a/scm-server/src/main/java/sonia/scm/server/ScmServer.java
+++ b/scm-server/src/main/java/sonia/scm/server/ScmServer.java
@@ -27,11 +27,6 @@ package sonia.scm.server;
//~--- non-JDK imports --------------------------------------------------------
import org.eclipse.jetty.server.Server;
-import org.eclipse.jetty.xml.XmlConfiguration;
-
-//~--- JDK imports ------------------------------------------------------------
-
-import java.net.URL;
/**
*
@@ -39,10 +34,6 @@ import java.net.URL;
*/
public class ScmServer extends Thread
{
-
- /** Field description */
- public static final String CONFIGURATION = "/server-config.xml";
-
/** Field description */
static final int GRACEFUL_TIMEOUT = 2000;
@@ -54,25 +45,9 @@ public class ScmServer extends Thread
*/
public ScmServer()
{
- URL configURL = ScmServer.class.getResource(CONFIGURATION);
-
- if (configURL == null)
- {
- throw new ScmServerException("could not find server-config.xml");
- }
-
+ ServerConfiguration config = new ServerConfiguration();
server = new org.eclipse.jetty.server.Server();
-
- try
- {
- XmlConfiguration config = new XmlConfiguration(configURL);
-
- config.configure(server);
- }
- catch (Exception ex)
- {
- throw new ScmServerException("error during server configuration", ex);
- }
+ config.configure(server);
}
//~--- methods --------------------------------------------------------------
diff --git a/scm-server/src/main/java/sonia/scm/server/ServerConfiguration.java b/scm-server/src/main/java/sonia/scm/server/ServerConfiguration.java
new file mode 100644
index 0000000000..ac426680e1
--- /dev/null
+++ b/scm-server/src/main/java/sonia/scm/server/ServerConfiguration.java
@@ -0,0 +1,129 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.server;
+
+import org.eclipse.jetty.server.Connector;
+import org.eclipse.jetty.server.Handler;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.ServerConnector;
+import org.eclipse.jetty.server.handler.HandlerCollection;
+import org.eclipse.jetty.util.resource.Resource;
+import org.eclipse.jetty.webapp.WebAppContext;
+import org.eclipse.jetty.xml.XmlConfiguration;
+import org.xml.sax.SAXException;
+
+import java.io.IOException;
+import java.net.URL;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+public final class ServerConfiguration {
+
+ private static final String CONFIGURATION = "/server-config.xml";
+ @SuppressWarnings("java:S1075") // not a real uri
+ private static final String DEFAULT_CONTEXT_PATH = "/scm";
+
+ private final XmlConfiguration jettyConfiguration;
+
+ public ServerConfiguration() {
+ this(CONFIGURATION);
+ }
+
+ public ServerConfiguration(String configurationUrl) {
+ this.jettyConfiguration = read(configurationUrl);
+ }
+
+ public ServerConfiguration(Path configurationPath) {
+ this.jettyConfiguration = parse(Resource.newResource(configurationPath));
+ }
+
+ public void configure(Server server) {
+ try {
+ jettyConfiguration.configure(server);
+ } catch (Exception ex) {
+ throw new ScmServerException("error during server configuration", ex);
+ }
+ }
+
+ public List getListeners() {
+ List listeners = new ArrayList<>();
+
+ Server server = new Server();
+ configure(server);
+
+ String contextPath = findContextPath(server.getHandlers());
+ if (contextPath == null) {
+ contextPath = DEFAULT_CONTEXT_PATH;
+ }
+
+ for (Connector connector : server.getConnectors()) {
+ if (connector instanceof ServerConnector) {
+ ServerConnector serverConnector = (ServerConnector) connector;
+ String scheme = "http";
+ String protocol = serverConnector.getDefaultProtocol();
+ if ("SSL".equalsIgnoreCase(protocol) || "TLS".equalsIgnoreCase(protocol)) {
+ scheme = "https";
+ }
+ listeners.add(new Listener(scheme, serverConnector.getPort(), contextPath));
+ }
+ }
+
+ return listeners;
+ }
+
+ private String findContextPath(Handler[] handlers) {
+ for (Handler handler : handlers) {
+ if (handler instanceof WebAppContext) {
+ return ((WebAppContext) handler).getContextPath();
+ } else if (handler instanceof HandlerCollection) {
+ String contextPath = findContextPath(((HandlerCollection) handler).getHandlers());
+ if (contextPath != null) {
+ return contextPath;
+ }
+ }
+ }
+ return null;
+ }
+
+ private static XmlConfiguration read(String configurationUrl) {
+ URL configURL = ScmServer.class.getResource(configurationUrl);
+
+ if (configURL == null) {
+ throw new ScmServerException("could not find server-config.xml");
+ }
+
+ return parse(Resource.newResource(configURL));
+ }
+
+ private static XmlConfiguration parse(Resource resource) {
+ try {
+ return new XmlConfiguration(resource);
+ } catch (IOException | SAXException ex) {
+ throw new ScmServerException("could not read server configuration", ex);
+ }
+ }
+
+}
diff --git a/scm-server/src/test/java/sonia/scm/server/HealthCheckTest.java b/scm-server/src/test/java/sonia/scm/server/HealthCheckTest.java
new file mode 100644
index 0000000000..0dea0f689f
--- /dev/null
+++ b/scm-server/src/test/java/sonia/scm/server/HealthCheckTest.java
@@ -0,0 +1,311 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.server;
+
+import org.eclipse.jetty.http.HttpVersion;
+import org.eclipse.jetty.server.HttpConfiguration;
+import org.eclipse.jetty.server.HttpConnectionFactory;
+import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.ServerConnector;
+import org.eclipse.jetty.server.SslConnectionFactory;
+import org.eclipse.jetty.server.handler.AbstractHandler;
+import org.eclipse.jetty.util.ssl.SslContextFactory;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.KeyStore;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class HealthCheckTest {
+
+ private List servers;
+
+ @Test
+ void shouldReturnZero() throws Exception {
+ int port = startHttpServer("/scm");
+
+ HealthCheck check = new HealthCheck(new Listener("http", port, "/scm"));
+ assertThat(check.call()).isZero();
+ }
+
+ @Test
+ void shouldReturnNonZeroForWrongContextPath() throws Exception {
+ int port = startHttpServer("/scm");
+ HealthCheck check = new HealthCheck(new Listener("http", port, "/myscm"));
+ assertThat(check.call()).isPositive();
+ }
+
+ @Test
+ void shouldReturnNonZeroForWrongPort() throws Exception {
+ int port = startHttpServer("/scm");
+ HealthCheck check = new HealthCheck(new Listener("http", port + 1, "/scm"));
+ assertThat(check.call()).isPositive();
+ }
+
+ @Test
+ void shouldReturnZeroForMultipleListeners() throws Exception {
+ int one = startHttpServer("/scm");
+ int two = startHttpServer("/scm");
+
+ HealthCheck check = new HealthCheck(
+ new Listener("http", one, "/scm"),
+ new Listener("http", two, "/scm")
+ );
+
+ assertThat(check.call()).isZero();
+ }
+
+ @Test
+ void shouldReturnNonZeroIfOneFails() throws Exception {
+ int one = startHttpServer("/myscm");
+ int two = startHttpServer("/scm");
+
+ HealthCheck check = new HealthCheck(
+ new Listener("http", one, "/myscm"),
+ new Listener("http", two, "/myscm")
+ );
+
+ assertThat(check.call()).isPositive();
+ }
+
+ @Test
+ void shouldHandleHttps(@TempDir Path directory) throws Exception {
+ int port = startHttpsServer(directory, "/scm");
+
+ HealthCheck check = new HealthCheck(new Listener("https", port, "/scm"));
+ assertThat(check.call()).isZero();
+ }
+
+ @Test
+ void shouldHandleHttpAndHttps(@TempDir Path directory) throws Exception {
+ int http = startHttpServer("/scm");
+ int https = startHttpsServer(directory, "/scm");
+
+ HealthCheck check = new HealthCheck(
+ new Listener("http", http, "/scm"),
+ new Listener("https", https, "/scm")
+ );
+ assertThat(check.call()).isZero();
+ }
+
+ @Test
+ void shouldFollowRedirect() throws Exception {
+ int http = startHttpServer("/scm");
+ int redirector = startHttpRedirector("/scm", "http", http);
+
+ HealthCheck check = new HealthCheck(
+ new Listener("http", redirector, "/scm")
+ );
+ assertThat(check.call()).isZero();
+ }
+
+ @Test
+ void shouldFailWithInvalidRedirect() throws Exception {
+ int redirector = startHttpRedirector("/scm", "http", 9999);
+
+ HealthCheck check = new HealthCheck(
+ new Listener("http", redirector, "/scm")
+ );
+ assertThat(check.call()).isPositive();
+ }
+
+ @Test
+ void shouldFailOnRedirectWithouLocation() throws Exception {
+ int redirector = startInvalidRedirector("/scm");
+
+ HealthCheck check = new HealthCheck(
+ new Listener("http", redirector, "/scm")
+ );
+ assertThat(check.call()).isPositive();
+ }
+
+ @Test
+ void shouldFollowRedirectFromHttpToHttps(@TempDir Path directory) throws Exception {
+ int https = startHttpsServer(directory,"/scm");
+ int redirector = startHttpRedirector("/scm", "https", https);
+
+ HealthCheck check = new HealthCheck(
+ new Listener("http", redirector, "/scm")
+ );
+ assertThat(check.call()).isZero();
+ }
+
+ @BeforeEach
+ private void setUp() {
+ servers = new ArrayList<>();
+ }
+
+ @AfterEach
+ private void shutdown() {
+ for (Server server : servers) {
+ try {
+ server.stop();
+ } catch (Exception e) {
+ // do nothing
+ }
+ }
+ }
+
+ private int startHttpServer(String contextPath) throws Exception {
+ Server server = new Server(0);
+ return start(contextPath, server);
+ }
+
+ private int start(String contextPath, Server server) throws Exception {
+ server.setHandler(new AbstractHandler() {
+ @Override
+ public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) {
+ response.setContentType("text/plain; charset=utf-8");
+ if (target.equals(contextPath + "/api/v2")) {
+ response.setStatus(HttpServletResponse.SC_OK);
+ } else {
+ response.setStatus(HttpServletResponse.SC_NOT_FOUND);
+ }
+ baseRequest.setHandled(true);
+ }
+ });
+
+
+ server.start();
+ servers.add(server);
+ return server.getURI().getPort();
+ }
+
+ private int startHttpsServer(Path directory, String contextPath) throws Exception {
+ Server server = new Server();
+
+ ServerConnector sslConnector = createSslConnector(server, directory, "changeit");
+ server.addConnector(sslConnector);
+
+ return start(contextPath, server);
+ }
+
+ private int startHttpRedirector(String contextPath, String targetScheme, int targetPort) throws Exception {
+ Server server = new Server(0);
+ server.setHandler(new AbstractHandler() {
+ @Override
+ public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) {
+ response.setContentType("text/plain; charset=utf-8");
+ if (target.equals(contextPath + "/api/v2")) {
+ response.setHeader("Location", String.format("%s://127.0.0.1:%d%s", targetScheme, targetPort, target));
+ response.setStatus(HttpServletResponse.SC_TEMPORARY_REDIRECT);
+ } else {
+ response.setStatus(HttpServletResponse.SC_NOT_FOUND);
+ }
+ baseRequest.setHandled(true);
+ }
+ });
+
+
+ server.start();
+ servers.add(server);
+ return server.getURI().getPort();
+ }
+
+ private int startInvalidRedirector(String contextPath) throws Exception {
+ Server server = new Server(0);
+ server.setHandler(new AbstractHandler() {
+ @Override
+ public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) {
+ response.setContentType("text/plain; charset=utf-8");
+ if (target.equals(contextPath + "/api/v2")) {
+ response.setStatus(HttpServletResponse.SC_FOUND);
+ } else {
+ response.setStatus(HttpServletResponse.SC_NOT_FOUND);
+ }
+ baseRequest.setHandled(true);
+ }
+ });
+
+
+ server.start();
+ servers.add(server);
+ return server.getURI().getPort();
+ }
+
+ private ServerConnector createSslConnector(Server server, Path directory, String password) throws Exception {
+ Path keystorePath = createSelfSignedKeyStore(directory, password);
+ KeyStore keyStore = createKeyStore(keystorePath, password);
+ SslContextFactory sslContextFactory = createSslContextFactory(keyStore, password);
+
+ ServerConnector sslConnector = new ServerConnector(
+ server,
+ new SslConnectionFactory(sslContextFactory, HttpVersion.HTTP_1_1.asString()),
+ new HttpConnectionFactory(new HttpConfiguration())
+ );
+
+ sslConnector.setPort(0);
+
+ return sslConnector;
+ }
+
+ private SslContextFactory createSslContextFactory(KeyStore keyStore, String password) {
+ SslContextFactory sslContextFactory = new SslContextFactory.Server();
+ sslContextFactory.setKeyStore(keyStore);
+ sslContextFactory.setKeyStorePassword(password);
+ return sslContextFactory;
+ }
+
+ private KeyStore createKeyStore(Path keystorePath, String password) throws Exception {
+ KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
+ try (InputStream stream = Files.newInputStream(keystorePath)) {
+ keyStore.load(stream, password.toCharArray());
+ }
+ return keyStore;
+ }
+
+ private Path createSelfSignedKeyStore(Path directory, String password) throws IOException, InterruptedException {
+ // no way to create a self-signed certificate from an api, so we use keytool for now
+ int result = new ProcessBuilder("keytool",
+ "-genkey",
+ "-keyalg", "RSA",
+ "-alias", "selfsigned",
+ "-keystore", "keystore",
+ "-storepass", password,
+ "-validity", "360",
+ "-keysize", "1024",
+ "-dname", "CN=localhost"
+ )
+ .directory(directory.toFile())
+ .start()
+ .waitFor();
+ if (result != 0) {
+ throw new IOException("failed to generate self signed certificate");
+ }
+ return directory.resolve("keystore");
+ }
+}
diff --git a/scm-server/src/test/java/sonia/scm/server/ServerConfigurationTest.java b/scm-server/src/test/java/sonia/scm/server/ServerConfigurationTest.java
new file mode 100644
index 0000000000..b630530ba0
--- /dev/null
+++ b/scm-server/src/test/java/sonia/scm/server/ServerConfigurationTest.java
@@ -0,0 +1,103 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.server;
+
+import com.google.common.io.Resources;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junitpioneer.jupiter.ClearSystemProperty;
+import org.junitpioneer.jupiter.SetSystemProperty;
+
+import java.io.IOException;
+import java.net.URL;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@ClearSystemProperty(key = "basedir")
+class ServerConfigurationTest {
+
+ @Test
+ void shouldReturnDefaultListener(@TempDir Path directory) throws IOException {
+ ServerConfiguration configuration = configure(directory, "default.xml");
+
+ assertThat(configuration.getListeners())
+ .hasSize(1)
+ .allSatisfy(listener -> {
+ assertThat(listener.getScheme()).isEqualTo("http");
+ assertThat(listener.getPort()).isEqualTo(8080);
+ assertThat(listener.getContextPath()).isEqualTo("/scm");
+ });
+ }
+
+ @Test
+ @SetSystemProperty(key = "jetty.port", value = "8081")
+ void shouldReturnCustomContextPathAndPort(@TempDir Path directory) throws IOException {
+ ServerConfiguration configuration = configure(directory, "ctxPath.xml");
+
+ assertThat(configuration.getListeners())
+ .hasSize(1)
+ .allSatisfy(listener -> {
+ assertThat(listener.getScheme()).isEqualTo("http");
+ assertThat(listener.getPort()).isEqualTo(8081);
+ assertThat(listener.getContextPath()).isEqualTo("/myscm");
+ });
+ }
+
+ @Test
+ void shouldReturnConfiguredSSListener(@TempDir Path directory) throws IOException {
+ ServerConfiguration configuration = configure(directory, "ssl.xml");
+
+ assertThat(configuration.getListeners())
+ .hasSize(2)
+ .anySatisfy(listener -> {
+ assertThat(listener.getScheme()).isEqualTo("http");
+ assertThat(listener.getPort()).isEqualTo(80);
+ assertThat(listener.getContextPath()).isEqualTo("/scm");
+ })
+ .anySatisfy(listener -> {
+ assertThat(listener.getScheme()).isEqualTo("https");
+ assertThat(listener.getPort()).isEqualTo(443);
+ assertThat(listener.getContextPath()).isEqualTo("/scm");
+ });
+ ;
+ }
+
+ @SuppressWarnings("UnstableApiUsage")
+ private ServerConfiguration configure(Path directory, String configurationFilename) throws IOException {
+ URL resource = Resources.getResource("sonia/scm/server/" + configurationFilename);
+ Path path = directory.resolve("server-config.xml");
+
+ Files.write(path, Resources.toByteArray(resource));
+ Files.createDirectories(directory.resolve("var/webapp/docroot"));
+ Files.createFile(directory.resolve("var/webapp/scm-webapp.war"));
+
+ System.setProperty("basedir", directory.toString());
+
+ return new ServerConfiguration(path);
+ }
+
+}
diff --git a/scm-server/src/test/resources/sonia/scm/server/ctxPath.xml b/scm-server/src/test/resources/sonia/scm/server/ctxPath.xml
new file mode 100644
index 0000000000..ccf2e70f4c
--- /dev/null
+++ b/scm-server/src/test/resources/sonia/scm/server/ctxPath.xml
@@ -0,0 +1,120 @@
+
+
+
+
+
+
+
+
+
+
+ 16384
+ 16384
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /myscm
+
+ /var/webapp/scm-webapp.war
+
+
+
+ org.eclipse.jetty.servlet.Default.dirAllowed
+ false
+
+ /var/cache/scm/work/webapp
+
+
+
+ /
+
+
+
+
+ -
+ /var/webapp/docroot
+
+
+
+
+ /var/cache/scm/work/work/docroot
+
+
+
+
+
+
+ -
+
+
+ -
+
+
+
+
+
+
+
+
diff --git a/scm-server/src/test/resources/sonia/scm/server/default.xml b/scm-server/src/test/resources/sonia/scm/server/default.xml
new file mode 100644
index 0000000000..6a16453b6e
--- /dev/null
+++ b/scm-server/src/test/resources/sonia/scm/server/default.xml
@@ -0,0 +1,120 @@
+
+
+
+
+
+
+
+
+
+
+ 16384
+ 16384
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /scm
+
+ /var/webapp/scm-webapp.war
+
+
+
+ org.eclipse.jetty.servlet.Default.dirAllowed
+ false
+
+ /var/cache/scm/work/webapp
+
+
+
+ /
+
+
+
+
+ -
+ /var/webapp/docroot
+
+
+
+
+ /var/cache/scm/work/work/docroot
+
+
+
+
+
+
+ -
+
+
+ -
+
+
+
+
+
+
+
+
diff --git a/scm-server/src/test/resources/sonia/scm/server/ssl.xml b/scm-server/src/test/resources/sonia/scm/server/ssl.xml
new file mode 100644
index 0000000000..a2b48c487e
--- /dev/null
+++ b/scm-server/src/test/resources/sonia/scm/server/ssl.xml
@@ -0,0 +1,227 @@
+
+
+
+
+
+
+
+
+
+
+ 16384
+ 16384
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /scm
+
+ /var/webapp/scm-webapp.war
+
+
+
+ org.eclipse.jetty.servlet.Default.dirAllowed
+ false
+
+ /var/cache/scm/work/webapp
+
+
+
+ /
+
+
+
+
+ -
+ /var/webapp/docroot
+
+
+
+
+ /var/cache/scm/work/work/docroot
+
+
+
+
+
+
+ -
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+ /scm/certs/certificates.p12
+
+
+ PKCS12
+
+ changeit
+
+
+
+
+
+
+ - TLSv1.2
+ - TLSv1.3
+
+
+
+
+
+ - TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
+ - TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
+ - TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
+ - TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
+ - TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256
+ - TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256
+ - TLS_DHE_RSA_WITH_AES_256_GCM_SHA384
+ - TLS_DHE_RSA_WITH_AES_128_GCM_SHA256
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+ http/1.1
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+