diff --git a/src/main/java/org/qortal/api/DevProxyService.java b/src/main/java/org/qortal/api/DevProxyService.java new file mode 100644 index 00000000..e0bf02db --- /dev/null +++ b/src/main/java/org/qortal/api/DevProxyService.java @@ -0,0 +1,173 @@ +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.network.Network; +import org.qortal.repository.DataException; +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 DevProxyService { + + private static DevProxyService instance; + + private final ResourceConfig config; + private Server server; + + private DevProxyService() { + this.config = new ResourceConfig(); + this.config.packages("org.qortal.api.proxy.resource", "org.qortal.api.resource"); + this.config.register(OpenApiResource.class); + this.config.register(ApiDefinition.class); + this.config.register(AnnotationPostProcessor.class); + } + + public static DevProxyService getInstance() { + if (instance == null) + instance = new DevProxyService(); + + return instance; + } + + public Iterable> getResources() { + return this.config.getClasses(); + } + + public void start() throws DataException { + 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().getDevProxyPort()); + + 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(Network.getInstance().getBindAddress()); + portUnifiedConnector.setPort(Settings.getInstance().getDevProxyPort()); + + this.server.addConnector(portUnifiedConnector); + } else { + // Non-SSL + InetAddress bindAddr = InetAddress.getByName(Network.getInstance().getBindAddress()); + InetSocketAddress endpoint = new InetSocketAddress(bindAddr, Settings.getInstance().getDevProxyPort()); + this.server = new Server(endpoint); + } + + // Error handler + ErrorHandler errorHandler = new ApiErrorHandler(); + this.server.setErrorHandler(errorHandler); + + // Request logging + if (Settings.getInstance().isDevProxyLoggingEnabled()) { + RequestLogWriter logWriter = new RequestLogWriter("devproxy-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 DataException("Failed to start developer proxy", e); + } + } + + public void stop() { + try { + // Stop server + this.server.stop(); + } catch (Exception e) { + // Failed to stop + } + + this.server = null; + instance = null; + } + +} diff --git a/src/main/java/org/qortal/api/proxy/resource/DevProxyServerResource.java b/src/main/java/org/qortal/api/proxy/resource/DevProxyServerResource.java new file mode 100644 index 00000000..d51e6852 --- /dev/null +++ b/src/main/java/org/qortal/api/proxy/resource/DevProxyServerResource.java @@ -0,0 +1,142 @@ +package org.qortal.api.proxy.resource; + +import org.qortal.api.ApiError; +import org.qortal.api.ApiExceptionFactory; +import org.qortal.api.HTMLParser; +import org.qortal.arbitrary.misc.Service; +import org.qortal.controller.DevProxyManager; + +import javax.servlet.ServletContext; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.core.Context; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.Enumeration; + + +@Path("/") +public class DevProxyServerResource { + + @Context HttpServletRequest request; + @Context HttpServletResponse response; + @Context ServletContext context; + + + @GET + public HttpServletResponse getProxyIndex() { + return this.proxy("/"); + } + + @GET + @Path("{path:.*}") + public HttpServletResponse getProxyPath(@PathParam("path") String inPath) { + return this.proxy(inPath); + } + + private HttpServletResponse proxy(String inPath) { + try { + String source = DevProxyManager.getInstance().getSourceHostAndPort(); + + // Convert localhost / 127.0.0.1 to IPv6 [::1] + if (source.startsWith("localhost") || source.startsWith("127.0.0.1")) { + int port = 80; + String[] parts = source.split(":"); + if (parts.length > 1) { + port = Integer.parseInt(parts[1]); + } + source = String.format("[::1]:%d", port); + } + + if (!inPath.startsWith("/")) { + inPath = "/" + inPath; + } + + String queryString = request.getQueryString() != null ? "?" + request.getQueryString() : ""; + + // Open URL + String urlString = String.format("http://%s%s%s", source, inPath, queryString); + URL url = new URL(urlString); + HttpURLConnection con = (HttpURLConnection) url.openConnection(); + con.setRequestMethod(request.getMethod()); + + // Proxy the request headers + Enumeration headerNames = request.getHeaderNames(); + while (headerNames.hasMoreElements()) { + String headerName = headerNames.nextElement(); + String headerValue = request.getHeader(headerName); + con.setRequestProperty(headerName, headerValue); + } + + // TODO: proxy any POST parameters from "request" to "con" + + // Proxy the response code + int responseCode = con.getResponseCode(); + response.setStatus(responseCode); + + // Proxy the response headers + for (int i = 0; ; i++) { + String headerKey = con.getHeaderFieldKey(i); + String headerValue = con.getHeaderField(i); + if (headerKey != null && headerValue != null) { + response.addHeader(headerKey, headerValue); + continue; + } + break; + } + + // Read the response body + InputStream inputStream = con.getInputStream(); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + byte[] data = outputStream.toByteArray(); // TODO: limit file size that can be read into memory + + // Close the streams + outputStream.close(); + inputStream.close(); + + // Extract filename + String filename = ""; + if (inPath.contains("/")) { + String[] parts = inPath.split("/"); + if (parts.length > 0) { + filename = parts[parts.length - 1]; + } + } + + // Parse and modify output if needed + if (HTMLParser.isHtmlFile(filename)) { + // HTML file - needs to be parsed + HTMLParser htmlParser = new HTMLParser("", inPath, "", false, data, "proxy", Service.APP, null, "light", true); + htmlParser.addAdditionalHeaderTags(); + response.addHeader("Content-Security-Policy", "default-src 'self' 'unsafe-inline' 'unsafe-eval'; media-src 'self' data: blob:; img-src 'self' data: blob:; connect-src 'self' ws:; font-src 'self' data:;"); + response.setContentType(con.getContentType()); + response.setContentLength(htmlParser.getData().length); + response.getOutputStream().write(htmlParser.getData()); + } + else { + // Regular file - can be streamed directly + response.addHeader("Content-Security-Policy", "default-src 'self'"); + response.setContentType(con.getContentType()); + response.setContentLength(data.length); + response.getOutputStream().write(data); + } + + } catch (IOException e) { + throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, e.getMessage()); + } + + return response; + } + +} diff --git a/src/main/java/org/qortal/api/resource/DeveloperResource.java b/src/main/java/org/qortal/api/resource/DeveloperResource.java new file mode 100644 index 00000000..ba534502 --- /dev/null +++ b/src/main/java/org/qortal/api/resource/DeveloperResource.java @@ -0,0 +1,96 @@ +package org.qortal.api.resource; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.qortal.api.ApiError; +import org.qortal.api.ApiErrors; +import org.qortal.api.ApiExceptionFactory; +import org.qortal.controller.DevProxyManager; +import org.qortal.repository.DataException; + +import javax.servlet.ServletContext; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; + + +@Path("/developer") +@Tag(name = "Developer Tools") +public class DeveloperResource { + + @Context HttpServletRequest request; + @Context HttpServletResponse response; + @Context ServletContext context; + + + @POST + @Path("/proxy/start") + @Operation( + summary = "Start proxy server, for real time QDN app/website development", + requestBody = @RequestBody( + description = "Host and port of source webserver to be proxied", + required = true, + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string", + example = "127.0.0.1:5173" + ) + ) + ), + responses = { + @ApiResponse( + description = "Port number of running server", + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "number" + ) + ) + ) + } + ) + @ApiErrors({ApiError.INVALID_CRITERIA}) + public Integer startProxy(String sourceHostAndPort) { + // TODO: API key + DevProxyManager devProxyManager = DevProxyManager.getInstance(); + try { + devProxyManager.setSourceHostAndPort(sourceHostAndPort); + devProxyManager.start(); + return devProxyManager.getPort(); + + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, e.getMessage()); + } + } + + @POST + @Path("/proxy/stop") + @Operation( + summary = "Stop proxy server", + responses = { + @ApiResponse( + description = "true if stopped", + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "boolean" + ) + ) + ) + } + ) + public boolean stopProxy() { + DevProxyManager devProxyManager = DevProxyManager.getInstance(); + devProxyManager.stop(); + return !devProxyManager.isRunning(); + } + +} \ No newline at end of file diff --git a/src/main/java/org/qortal/controller/DevProxyManager.java b/src/main/java/org/qortal/controller/DevProxyManager.java new file mode 100644 index 00000000..a04e87ac --- /dev/null +++ b/src/main/java/org/qortal/controller/DevProxyManager.java @@ -0,0 +1,74 @@ +package org.qortal.controller; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.api.DevProxyService; +import org.qortal.repository.DataException; +import org.qortal.settings.Settings; + +public class DevProxyManager { + + protected static final Logger LOGGER = LogManager.getLogger(DevProxyManager.class); + + private static DevProxyManager instance; + + private boolean running = false; + + private String sourceHostAndPort = "127.0.0.1:5173"; // Default for React/Vite + + private DevProxyManager() { + + } + + public static DevProxyManager getInstance() { + if (instance == null) + instance = new DevProxyManager(); + + return instance; + } + + public void start() throws DataException { + synchronized(this) { + if (this.running) { + // Already running + return; + } + + LOGGER.info(String.format("Starting developer proxy service on port %d", Settings.getInstance().getDevProxyPort())); + DevProxyService devProxyService = DevProxyService.getInstance(); + devProxyService.start(); + this.running = true; + } + } + + public void stop() { + synchronized(this) { + if (!this.running) { + // Not running + return; + } + + LOGGER.info(String.format("Shutting down developer proxy service")); + DevProxyService devProxyService = DevProxyService.getInstance(); + devProxyService.stop(); + this.running = false; + } + } + + public void setSourceHostAndPort(String sourceHostAndPort) { + this.sourceHostAndPort = sourceHostAndPort; + } + + public String getSourceHostAndPort() { + return this.sourceHostAndPort; + } + + public Integer getPort() { + return Settings.getInstance().getDevProxyPort(); + } + + public boolean isRunning() { + return this.running; + } + +} diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index cce3f441..c3d5a0c8 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -47,6 +47,9 @@ public class Settings { private static final int MAINNET_GATEWAY_PORT = 80; private static final int TESTNET_GATEWAY_PORT = 8080; + private static final int MAINNET_DEV_PROXY_PORT = 12393; + private static final int TESTNET_DEV_PROXY_PORT = 62393; + private static final Logger LOGGER = LogManager.getLogger(Settings.class); private static final String SETTINGS_FILENAME = "settings.json"; @@ -107,6 +110,11 @@ public class Settings { private boolean gatewayLoggingEnabled = false; private boolean gatewayLoopbackEnabled = false; + // Developer Proxy + private Integer devProxyPort; + private boolean devProxyLoggingEnabled = false; + + // Specific to this node private boolean wipeUnconfirmedOnStart = false; /** Maximum number of unconfirmed transactions allowed per account */ @@ -649,6 +657,18 @@ public class Settings { } + public int getDevProxyPort() { + if (this.devProxyPort != null) + return this.devProxyPort; + + return this.isTestNet ? TESTNET_DEV_PROXY_PORT : MAINNET_DEV_PROXY_PORT; + } + + public boolean isDevProxyLoggingEnabled() { + return this.devProxyLoggingEnabled; + } + + public boolean getWipeUnconfirmedOnStart() { return this.wipeUnconfirmedOnStart; }