diff --git a/src/main/java/org/qortal/api/GatewayService.java b/src/main/java/org/qortal/api/GatewayService.java new file mode 100644 index 00000000..2ace838a --- /dev/null +++ b/src/main/java/org/qortal/api/GatewayService.java @@ -0,0 +1,170 @@ +package org.qortal.api; + +import io.swagger.v3.jaxrs2.integration.resources.OpenApiResource; +import org.eclipse.jetty.http.HttpVersion; +import org.eclipse.jetty.rewrite.handler.RewriteHandler; +import org.eclipse.jetty.server.*; +import org.eclipse.jetty.server.handler.ErrorHandler; +import org.eclipse.jetty.server.handler.InetAccessHandler; +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; +import org.qortal.api.resource.ApiDefinition; +import org.qortal.settings.Settings; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +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; + +public class GatewayService { + + private static GatewayService instance; + + private final ResourceConfig config; + private Server server; + + private GatewayService() { + this.config = new ResourceConfig(); + this.config.packages("org.qortal.api.gateway.resource"); + this.config.register(OpenApiResource.class); + this.config.register(ApiDefinition.class); + this.config.register(AnnotationPostProcessor.class); + } + + public static GatewayService getInstance() { + if (instance == null) + instance = new GatewayService(); + + return instance; + } + + public Iterable> getResources() { + return this.config.getClasses(); + } + + public void start() { + try { + // Create API server + + // 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().getGatewayServicePort()); + + 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 DetectorConnectionFactory(sslConnectionFactory), + httpConnectionFactory); + portUnifiedConnector.setHost(Settings.getInstance().getBindAddress()); + portUnifiedConnector.setPort(Settings.getInstance().getGatewayServicePort()); + + this.server.addConnector(portUnifiedConnector); + } else { + // Non-SSL + InetAddress bindAddr = InetAddress.getByName(Settings.getInstance().getBindAddress()); + InetSocketAddress endpoint = new InetSocketAddress(bindAddr, Settings.getInstance().getGatewayServicePort()); + this.server = new Server(endpoint); + } + + // Error handler + ErrorHandler errorHandler = new ApiErrorHandler(); + this.server.setErrorHandler(errorHandler); + + // Request logging + if (Settings.getInstance().isGatewayLoggingEnabled()) { + RequestLogWriter logWriter = new RequestLogWriter("gateway-requests.log"); + logWriter.setAppend(true); + logWriter.setTimeZone("UTC"); + RequestLog requestLog = new CustomRequestLog(logWriter, CustomRequestLog.EXTENDED_NCSA_FORMAT); + this.server.setRequestLog(requestLog); + } + + // Access handler (currently no whitelist is used) + InetAccessHandler accessHandler = new InetAccessHandler(); + this.server.setHandler(accessHandler); + + // URL rewriting + RewriteHandler rewriteHandler = new RewriteHandler(); + accessHandler.setHandler(rewriteHandler); + + // Context + ServletContextHandler context = new ServletContextHandler(ServletContextHandler.NO_SESSIONS); + context.setContextPath("/"); + rewriteHandler.setHandler(context); + + // Cross-origin resource sharing + FilterHolder corsFilterHolder = new FilterHolder(CrossOriginFilter.class); + corsFilterHolder.setInitParameter(CrossOriginFilter.ALLOWED_ORIGINS_PARAM, "*"); + corsFilterHolder.setInitParameter(CrossOriginFilter.ALLOWED_METHODS_PARAM, "GET, POST, DELETE"); + corsFilterHolder.setInitParameter(CrossOriginFilter.CHAIN_PREFLIGHT_PARAM, "false"); + context.addFilter(corsFilterHolder, "/*", null); + + // API servlet + ServletContainer container = new ServletContainer(this.config); + ServletHolder apiServlet = new ServletHolder(container); + apiServlet.setInitOrder(1); + context.addServlet(apiServlet, "/*"); + + // Start server + this.server.start(); + } catch (Exception e) { + // Failed to start + throw new RuntimeException("Failed to start API", e); + } + } + + public void stop() { + try { + // Stop server + this.server.stop(); + } catch (Exception e) { + // Failed to stop + } + + this.server = null; + } + +} diff --git a/src/main/java/org/qortal/api/gateway/resource/GatewayResource.java b/src/main/java/org/qortal/api/gateway/resource/GatewayResource.java new file mode 100644 index 00000000..890598b9 --- /dev/null +++ b/src/main/java/org/qortal/api/gateway/resource/GatewayResource.java @@ -0,0 +1,50 @@ +package org.qortal.api.gateway.resource; + +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.qortal.api.Security; +import org.qortal.arbitrary.ArbitraryDataFile.ResourceIdType; +import org.qortal.arbitrary.ArbitraryDataRenderer; +import org.qortal.arbitrary.misc.Service; + +import javax.servlet.ServletContext; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.*; +import javax.ws.rs.core.Context; + + +@Path("/") +@Tag(name = "Gateway") +public class GatewayResource { + + @Context HttpServletRequest request; + @Context HttpServletResponse response; + @Context ServletContext context; + + @GET + @Path("{name}/{path:.*}") + @SecurityRequirement(name = "apiKey") + public HttpServletResponse getPathByName(@PathParam("name") String name, + @PathParam("path") String inPath) { + Security.checkApiCallAllowed(request); + return this.get(name, ResourceIdType.NAME, Service.WEBSITE, inPath, null, "", true, true); + } + + @GET + @Path("{name}") + @SecurityRequirement(name = "apiKey") + public HttpServletResponse getIndexByName(@PathParam("name") String name) { + Security.checkApiCallAllowed(request); + return this.get(name, ResourceIdType.NAME, Service.WEBSITE, "/", null, "", true, true); + } + + private HttpServletResponse get(String resourceId, ResourceIdType resourceIdType, Service service, String inPath, + String secret58, String prefix, boolean usePrefix, boolean async) { + + ArbitraryDataRenderer renderer = new ArbitraryDataRenderer(resourceId, resourceIdType, service, inPath, + secret58, prefix, usePrefix, async, request, response, context); + return renderer.render(); + } + +} diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java index 8d1160a6..1135e954 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java @@ -87,7 +87,7 @@ public class ArbitraryDataRenderer { String unzippedPath = path.toString(); try { - String filename = this.getFilename(unzippedPath.toString(), inPath); + String filename = this.getFilename(unzippedPath, inPath); String filePath = unzippedPath + File.separator + filename; if (HTMLParser.isHtmlFile(filename)) { diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 7c89a405..1609dfa8 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -46,6 +46,7 @@ import org.qortal.account.PrivateKeyAccount; import org.qortal.account.PublicKeyAccount; import org.qortal.api.ApiService; import org.qortal.api.DomainMapService; +import org.qortal.api.GatewayService; import org.qortal.block.Block; import org.qortal.block.BlockChain; import org.qortal.block.BlockChain.BlockTimingByHeight; @@ -507,6 +508,19 @@ public class Controller extends Thread { return; // Not System.exit() so that GUI can display error } + if (Settings.getInstance().isGatewayServiceEnabled()) { + LOGGER.info(String.format("Starting gateway service on port %d", Settings.getInstance().getGatewayServicePort())); + try { + GatewayService gatewayService = GatewayService.getInstance(); + gatewayService.start(); + } catch (Exception e) { + LOGGER.error("Unable to start gateway service", e); + Controller.getInstance().shutdown(); + Gui.getInstance().fatalError("Gateway service failure", e); + return; // Not System.exit() so that GUI can display error + } + } + if (Settings.getInstance().isDomainMapServiceEnabled()) { LOGGER.info(String.format("Starting domain map service on port %d", Settings.getInstance().getDomainMapServicePort())); try { diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 35f6cd3e..8977d7b5 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -41,6 +41,9 @@ public class Settings { private static final int MAINNET_DOMAIN_MAP_SERVICE_PORT = 80; private static final int TESTNET_DOMAIN_MAP_SERVICE_PORT = 8080; + private static final int MAINNET_GATEWAY_SERVICE_PORT = 80; + private static final int TESTNET_GATEWAY_SERVICE_PORT = 8080; + private static final Logger LOGGER = LogManager.getLogger(Settings.class); private static final String SETTINGS_FILENAME = "settings.json"; @@ -93,6 +96,11 @@ public class Settings { private boolean domainMapLoggingEnabled = false; private List domainMap = null; + // Gateway + private Integer gatewayServicePort; + private boolean gatewayServiceEnabled = false; + private boolean gatewayLoggingEnabled = false; + // Specific to this node private boolean wipeUnconfirmedOnStart = false; /** Maximum number of unconfirmed transactions allowed per account */ @@ -538,6 +546,23 @@ public class Settings { return map; } + + public int getGatewayServicePort() { + if (this.gatewayServicePort != null) + return this.gatewayServicePort; + + return this.isTestNet ? TESTNET_GATEWAY_SERVICE_PORT : MAINNET_GATEWAY_SERVICE_PORT; + } + + public boolean isGatewayServiceEnabled() { + return this.gatewayServiceEnabled; + } + + public boolean isGatewayLoggingEnabled() { + return this.gatewayLoggingEnabled; + } + + public boolean getWipeUnconfirmedOnStart() { return this.wipeUnconfirmedOnStart; }