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 + + + + + + + + + + + + + + + + + + + + + + + + + + + +