From c00ab2f87c952dab6fb473bbf6fc393660edefbc Mon Sep 17 00:00:00 2001 From: catbref Date: Tue, 23 Jun 2020 14:27:40 +0100 Subject: [PATCH] Add support for HTTPS for API Requires entries 'sslKeystorePathname' and 'sslKeystorePassword' in settings.json. With SSL enabled, API will auto-detect HTTP or HTTPs on the same port. Included tools/build-keystore.sh to help build keystore from Let's Encrypt certificates. --- src/main/java/org/qortal/api/ApiService.java | 72 ++++++++++++++++++- .../java/org/qortal/settings/Settings.java | 11 +++ tools/build-keystore.sh | 47 ++++++++++++ 3 files changed, 127 insertions(+), 3 deletions(-) create mode 100755 tools/build-keystore.sh diff --git a/src/main/java/org/qortal/api/ApiService.java b/src/main/java/org/qortal/api/ApiService.java index c8d1d27d..1bddef5f 100644 --- a/src/main/java/org/qortal/api/ApiService.java +++ b/src/main/java/org/qortal/api/ApiService.java @@ -2,15 +2,31 @@ package org.qortal.api; import io.swagger.v3.jaxrs2.integration.resources.OpenApiResource; +import java.io.InputStream; import java.net.InetAddress; import java.net.InetSocketAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.KeyStore; +import java.security.SecureRandom; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; + +import org.eclipse.jetty.http.HttpVersion; import org.eclipse.jetty.rewrite.handler.RedirectPatternRule; import org.eclipse.jetty.rewrite.handler.RewriteHandler; import org.eclipse.jetty.server.CustomRequestLog; +import org.eclipse.jetty.server.HttpConfiguration; +import org.eclipse.jetty.server.HttpConnectionFactory; +import org.eclipse.jetty.server.OptionalSslConnectionFactory; import org.eclipse.jetty.server.RequestLog; import org.eclipse.jetty.server.RequestLogWriter; +import org.eclipse.jetty.server.SecureRequestCustomizer; import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.server.SslConnectionFactory; import org.eclipse.jetty.server.handler.ErrorHandler; import org.eclipse.jetty.server.handler.InetAccessHandler; import org.eclipse.jetty.servlet.DefaultServlet; @@ -18,6 +34,7 @@ import org.eclipse.jetty.servlet.FilterHolder; import org.eclipse.jetty.servlet.ServletContextHandler; import org.eclipse.jetty.servlet.ServletHolder; import org.eclipse.jetty.servlets.CrossOriginFilter; +import org.eclipse.jetty.util.ssl.SslContextFactory; import org.glassfish.jersey.server.ResourceConfig; import org.glassfish.jersey.servlet.ServletContainer; import org.qortal.api.resource.AnnotationPostProcessor; @@ -56,9 +73,58 @@ public class ApiService { public void start() { try { // Create API server - InetAddress bindAddr = InetAddress.getByName(Settings.getInstance().getBindAddress()); - InetSocketAddress endpoint = new InetSocketAddress(bindAddr, Settings.getInstance().getApiPort()); - this.server = new Server(endpoint); + + // SSL support if requested + String keystorePathname = Settings.getInstance().getSslKeystorePathname(); + String keystorePassword = Settings.getInstance().getSslKeystorePassword(); + + if (keystorePathname != null && keystorePassword != null) { + // SSL version + if (!Files.isReadable(Path.of(keystorePathname))) + throw new RuntimeException("Failed to start SSL API due to broken keystore"); + + // BouncyCastle-specific SSLContext build + SSLContext sslContext = SSLContext.getInstance("TLS", "BCJSSE"); + KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance("PKIX", "BCJSSE"); + + KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType(), "BC"); + + try (InputStream keystoreStream = Files.newInputStream(Paths.get(keystorePathname))) { + keyStore.load(keystoreStream, keystorePassword.toCharArray()); + } + + keyManagerFactory.init(keyStore, keystorePassword.toCharArray()); + sslContext.init(keyManagerFactory.getKeyManagers(), null, new SecureRandom()); + + SslContextFactory.Server sslContextFactory = new SslContextFactory.Server(); + sslContextFactory.setSslContext(sslContext); + + this.server = new Server(); + + HttpConfiguration httpConfig = new HttpConfiguration(); + httpConfig.setSecureScheme("https"); + httpConfig.setSecurePort(Settings.getInstance().getApiPort()); + + SecureRequestCustomizer src = new SecureRequestCustomizer(); + httpConfig.addCustomizer(src); + + HttpConnectionFactory httpConnectionFactory = new HttpConnectionFactory(httpConfig); + SslConnectionFactory sslConnectionFactory = new SslConnectionFactory(sslContextFactory, HttpVersion.HTTP_1_1.asString()); + + ServerConnector portUnifiedConnector = new ServerConnector(this.server, + new OptionalSslConnectionFactory(sslConnectionFactory, HttpVersion.HTTP_1_1.asString()), + sslConnectionFactory, + httpConnectionFactory); + portUnifiedConnector.setHost(Settings.getInstance().getBindAddress()); + portUnifiedConnector.setPort(Settings.getInstance().getApiPort()); + + this.server.addConnector(portUnifiedConnector); + } else { + // Non-SSL + InetAddress bindAddr = InetAddress.getByName(Settings.getInstance().getBindAddress()); + InetSocketAddress endpoint = new InetSocketAddress(bindAddr, Settings.getInstance().getApiPort()); + this.server = new Server(endpoint); + } // Error handler ErrorHandler errorHandler = new ApiErrorHandler(); diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index eb026f63..321ccc84 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -63,6 +63,9 @@ public class Settings { private Boolean apiRestricted; private boolean apiLoggingEnabled = false; private boolean apiDocumentationEnabled = false; + // Both of these need to be set for API to use SSL + private String sslKeystorePathname = null; + private String sslKeystorePassword = null; // Specific to this node private boolean wipeUnconfirmedOnStart = false; @@ -295,6 +298,14 @@ public class Settings { return this.apiDocumentationEnabled; } + public String getSslKeystorePathname() { + return this.sslKeystorePathname; + } + + public String getSslKeystorePassword() { + return this.sslKeystorePassword; + } + public boolean getWipeUnconfirmedOnStart() { return this.wipeUnconfirmedOnStart; } diff --git a/tools/build-keystore.sh b/tools/build-keystore.sh new file mode 100755 index 00000000..4bb580d1 --- /dev/null +++ b/tools/build-keystore.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash + +set -e + +# Assumes Let's Encrypt + +if [ $# -ne 1 -a $# -ne 3 ]; then + echo "usage: ${0%%*/} [ ]" + exit 2 +fi + +domain=$1 +keystore=${2:-core-api.keystore} +pass=${3:-kspassword} + +LEdirs=(/usr/local/etc /etc /opt .) +for LEdir in "${LEdirs[@]}"; do + srcdir="${LEdir}/letsencrypt/live/${domain}" + if [ -d "$srcdir" ]; then + echo "Using certs & keys from ${srcdir}" + break; + fi + unset srcdir +done + +if [ -z "${srcdir}" ]; then + echo "Can't find Let's Encrypt folder for ${domain}" + exit +fi + +# key & cert +rm -f "${domain}.p12" +openssl pkcs12 \ + -inkey "${srcdir}/privkey.pem" -in "${srcdir}/fullchain.pem" \ + -export -out "${domain}.p12" -passout pass:"${pass}" \ + -name "${domain}" + +rm -f "${keystore}" +keytool -importkeystore -noprompt \ + -srckeystore "${domain}.p12" -srcstoretype PKCS12 -srcstorepass "${pass}" \ + -destkeystore "${keystore}" -deststorepass "${pass}" -destkeypass "${pass}" \ + -alias "${domain}" + +printf "Built keystore: ${keystore}, with password: ${pass}\nFor settings.json:\n" + +printf "\tsslKeystorePathname: \"%s\",\n" "${keystore}" +printf "\tsslKeystorePassword: \"%s\",\n" "${pass}"