diff --git a/META-INF/MANIFEST.MF b/META-INF/MANIFEST.MF new file mode 100644 index 000000000..6c987453e --- /dev/null +++ b/META-INF/MANIFEST.MF @@ -0,0 +1,5 @@ +Manifest-Version: 1.0 +Main-Class: io.gatehill.imposter.cmd.ImposterLauncher +Imposter-Version: 0.0.0-SNAPSHOT +Multi-Release: true + diff --git a/cmd/src/main/java/io/gatehill/imposter/cmd/ImposterLauncher.kt b/cmd/src/main/java/io/gatehill/imposter/cmd/ImposterLauncher.kt index 25e797a1e..6494401db 100644 --- a/cmd/src/main/java/io/gatehill/imposter/cmd/ImposterLauncher.kt +++ b/cmd/src/main/java/io/gatehill/imposter/cmd/ImposterLauncher.kt @@ -117,6 +117,9 @@ class ImposterLauncher(args: Array) { @Option(name = "--serverFactory", usage = "Fully qualified class for server factory") private var serverFactory: String = VertxWebServerFactoryImpl::class.java.canonicalName + + @Option(name = "--soap", usage = "Enable SOAP-aware mode for capturing requests/responses with SOAPAction") + private var soapMode: Boolean = false companion object { private val LOGGER = LogManager.getLogger(ImposterLauncher::class.java) @@ -204,6 +207,11 @@ class ImposterLauncher(args: Array) { imposterConfig.configDirs = configDirs imposterConfig.plugins = plugins imposterConfig.pluginArgs = splitArgs + imposterConfig.soapMode = soapMode + + if (soapMode) { + LOGGER.info("SOAP-aware mode enabled - requests and responses will be saved with SOAPAction-based filenames") + } } LifecycleAwareLauncher().dispatch(originalArgs) diff --git a/core/api/src/main/java/io/gatehill/imposter/ImposterConfig.kt b/core/api/src/main/java/io/gatehill/imposter/ImposterConfig.kt index a974b49a2..ecf6c39be 100644 --- a/core/api/src/main/java/io/gatehill/imposter/ImposterConfig.kt +++ b/core/api/src/main/java/io/gatehill/imposter/ImposterConfig.kt @@ -63,8 +63,9 @@ class ImposterConfig { var pluginDiscoveryStrategy: PluginDiscoveryStrategy? = null var pluginDiscoveryStrategyClass: String? = null var useEmbeddedScriptEngine: Boolean = false + var soapMode: Boolean = false override fun toString(): String { - return "ImposterConfig(host=$host, listenPort=$listenPort, configDirs=${configDirs.contentToString()}, serverUrl=$serverUrl, isTlsEnabled=$isTlsEnabled, keystorePath=$keystorePath, keystorePassword=$keystorePassword, plugins=${plugins?.contentToString()}, pluginArgs=$pluginArgs, serverFactory=$serverFactory, pluginDiscoveryStrategy=$pluginDiscoveryStrategy, pluginDiscoveryStrategyClass=$pluginDiscoveryStrategyClass, useEmbeddedScriptEngine=$useEmbeddedScriptEngine)" + return "ImposterConfig(host=$host, listenPort=$listenPort, configDirs=${configDirs.contentToString()}, serverUrl=$serverUrl, isTlsEnabled=$isTlsEnabled, keystorePath=$keystorePath, keystorePassword=$keystorePassword, plugins=${plugins?.contentToString()}, pluginArgs=$pluginArgs, serverFactory=$serverFactory, pluginDiscoveryStrategy=$pluginDiscoveryStrategy, pluginDiscoveryStrategyClass=$pluginDiscoveryStrategyClass, useEmbeddedScriptEngine=$useEmbeddedScriptEngine, soapMode=$soapMode)" } } diff --git a/core/engine/src/main/java/io/gatehill/imposter/inject/EngineModule.kt b/core/engine/src/main/java/io/gatehill/imposter/inject/EngineModule.kt index 0645cb0d3..7c2f81004 100644 --- a/core/engine/src/main/java/io/gatehill/imposter/inject/EngineModule.kt +++ b/core/engine/src/main/java/io/gatehill/imposter/inject/EngineModule.kt @@ -59,6 +59,7 @@ import io.gatehill.imposter.service.ResponseService import io.gatehill.imposter.service.ResponseServiceImpl import io.gatehill.imposter.service.ScriptedResponseService import io.gatehill.imposter.service.SecurityService +import io.gatehill.imposter.service.SoapAwareUpstreamService import io.gatehill.imposter.service.StepService import io.gatehill.imposter.service.UpstreamService import io.gatehill.imposter.service.script.EmbeddedScriptService @@ -99,5 +100,6 @@ internal class EngineModule : AbstractModule() { bind(RemoteService::class.java).asSingleton() bind(StepService::class.java).asSingleton() bind(UpstreamService::class.java).asSingleton() + bind(SoapAwareUpstreamService::class.java).asSingleton() } } diff --git a/core/engine/src/main/java/io/gatehill/imposter/service/HandlerServiceImpl.kt b/core/engine/src/main/java/io/gatehill/imposter/service/HandlerServiceImpl.kt index ae4daa2de..859a46daa 100644 --- a/core/engine/src/main/java/io/gatehill/imposter/service/HandlerServiceImpl.kt +++ b/core/engine/src/main/java/io/gatehill/imposter/service/HandlerServiceImpl.kt @@ -81,6 +81,8 @@ class HandlerServiceImpl @Inject constructor( private val interceptorService: InterceptorService, private val responseService: ResponseService, private val upstreamService: UpstreamService, + private val soapAwareUpstreamService: SoapAwareUpstreamService, + private val imposterConfig: ImposterConfig, ) : HandlerService, CoroutineScope by supervisedDefaultCoroutineScope { private val shouldAddEngineResponseHeaders: Boolean = @@ -339,11 +341,23 @@ class HandlerServiceImpl @Inject constructor( pluginConfig: PluginConfig, resourceConfig: BasicResourceConfig, httpExchange: HttpExchange, - ) = upstreamService.forwardToUpstream( - pluginConfig as UpstreamsHolder, - resourceConfig as PassthroughResourceConfig, - httpExchange - ) + ) = if (imposterConfig.soapMode) { + // Use SOAP-aware upstream service if SOAP mode is enabled + soapAwareUpstreamService.forwardToUpstream( + pluginConfig as UpstreamsHolder, + resourceConfig as PassthroughResourceConfig, + httpExchange, + imposterConfig.configDirs.firstOrNull() ?: ".", + imposterConfig.soapMode + ) + } else { + // Use standard upstream service + upstreamService.forwardToUpstream( + pluginConfig as UpstreamsHolder, + resourceConfig as PassthroughResourceConfig, + httpExchange + ) + } companion object { private val LOGGER = LogManager.getLogger(HandlerServiceImpl::class.java) diff --git a/core/engine/src/main/java/io/gatehill/imposter/service/SoapAwareUpstreamService.kt b/core/engine/src/main/java/io/gatehill/imposter/service/SoapAwareUpstreamService.kt new file mode 100644 index 000000000..44692d9a5 --- /dev/null +++ b/core/engine/src/main/java/io/gatehill/imposter/service/SoapAwareUpstreamService.kt @@ -0,0 +1,375 @@ +/* + * Copyright (c) 2023-2024. + * + * This file is part of Imposter. + * + * "Commons Clause" License Condition v1.0 + * + * The Software is provided to you by the Licensor under the License, as + * defined below, subject to the following condition. + * + * Without limiting other conditions in the License, the grant of rights + * under the License will not include, and the License does not grant to + * you, the right to Sell the Software. + * + * For purposes of the foregoing, "Sell" means practicing any or all of + * the rights granted to you under the License to provide to third parties, + * for a fee or other consideration (including without limitation fees for + * hosting or consulting/support services related to the Software), a + * product or service whose value derives, entirely or substantially, from + * the functionality of the Software. Any license notice or attribution + * required by the License must also include this Commons Clause License + * Condition notice. + * + * Software: Imposter + * + * License: GNU Lesser General Public License version 3 + * + * Licensor: Peter Cornish + * + * Imposter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Imposter is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Imposter. If not, see . + */ + +package io.gatehill.imposter.service + +import io.gatehill.imposter.exception.ResponseException +import io.gatehill.imposter.http.HttpExchange +import io.gatehill.imposter.plugin.config.resource.PassthroughResourceConfig +import io.gatehill.imposter.plugin.config.resource.UpstreamsHolder +import io.gatehill.imposter.util.HttpUtil +import io.gatehill.imposter.util.LogUtil +import io.gatehill.imposter.util.makeFuture +import io.vertx.core.buffer.Buffer +import okhttp3.Call +import okhttp3.Callback +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import org.apache.logging.log4j.LogManager +import java.io.File +import java.io.IOException +import java.net.URI +import java.util.concurrent.CompletableFuture +import javax.inject.Inject + +/** + * Enhanced proxy service that is SOAP-aware and can save requests/responses with SOAPAction-based filenames. + * Integrates the implementation of UpstreamService inside + * class only used when `--soap` extra argument is used + * + * @author Jose Luis + */ +class SoapAwareUpstreamService @Inject constructor( + private val responseService: ResponseService, +) { + private val logger = LogManager.getLogger(javaClass) + private val httpClient = OkHttpClient() + + /** + * Forward a request to an upstream service with SOAP awareness + */ + fun forwardToUpstream( + pluginConfig: UpstreamsHolder, + resourceConfig: PassthroughResourceConfig, + httpExchange: HttpExchange, + configDir: String, + soapMode: Boolean + ): CompletableFuture = makeFuture(autoComplete = false) { future -> + logger.info("Forwarding ${if (soapMode) "SOAP-aware " else ""}request ${LogUtil.describeRequest(httpExchange)} to upstream ${resourceConfig.passthrough}") + val call = buildCall(pluginConfig, resourceConfig, httpExchange) + + if (logger.isTraceEnabled) { + logger.trace("Request to upstream ${resourceConfig.passthrough}: ${call.request()}") + } + + try { + call.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + logger.error("Failed to forward request ${LogUtil.describeRequest(httpExchange)} to upstream ${call.request().url}", e) + future.completeExceptionally(e) + } + + override fun onResponse(call: Call, response: Response) { + if (soapMode) { + handleSoapResponse(configDir, resourceConfig, call.request(), response, httpExchange, future) + } else { + handleStandardResponse(resourceConfig, call.request(), response, httpExchange, future) + } + } + }) + } catch (e: Exception) { + logger.error("Failed to forward request ${LogUtil.describeRequest(httpExchange)} to upstream ${call.request().url}", e) + future.completeExceptionally(e) + } + } + + private fun buildCall(pluginConfig: UpstreamsHolder, resourceConfig: PassthroughResourceConfig, httpExchange: HttpExchange): Call { + try { + val upstream = pluginConfig.upstreams?.get(resourceConfig.passthrough) + ?: throw IllegalStateException("No upstream found for name: ${resourceConfig.passthrough}") + + val requestUri = URI(httpExchange.request.absoluteUri) + val upstreamUri = URI(upstream.url) + + val url = URI( + upstreamUri.scheme, + upstreamUri.userInfo ?: requestUri.userInfo, + upstreamUri.host, + upstreamUri.port, + HttpUtil.joinPaths(upstreamUri.path, requestUri.path), + requestUri.query, + requestUri.fragment + ) + + val request = Request.Builder().url(url.toURL()).apply { + httpExchange.request.headers.forEach { (name, value) -> + if (name !in skipProxyHeaders) { + addHeader(name, value) + } + } + }.method(httpExchange.request.method.name, httpExchange.request.body?.bytes?.toRequestBody()).build() + + return httpClient.newCall(request) + + } catch (e: Exception) { + throw RuntimeException("Failed to build upstream call for ${LogUtil.describeRequest(httpExchange)}", e) + } + } + + // same implementation than UpstreamService.handleResponse + private fun handleStandardResponse( + resourceConfig: PassthroughResourceConfig, + request: Request, + response: Response, + httpExchange: HttpExchange, + future: CompletableFuture, + ) { + try { + if (logger.isTraceEnabled) { + logger.trace("Response from upstream ${resourceConfig.passthrough}: $response") + } + + val body = response.body?.string() ?: "" + logger.debug( + "Received response from upstream ${resourceConfig.passthrough} (${request.url}) with status ${response.code} [body: ${body.length} bytes] for ${LogUtil.describeRequest(httpExchange)}" + ) + + with(httpExchange.response) { + setStatusCode(response.code) + response.headers.forEach { (name, value) -> + if (name !in skipProxyHeaders) { + putHeader(name, value) + } + } + } + responseService.sendThenFinaliseExchange(resourceConfig, httpExchange) { + try { + responseService.writeResponseData( + resourceConfig, + httpExchange, + filenameHintForContentType = null, + Buffer.buffer(body), + template = false, + trustedData = false + ) + } catch (e: Exception) { + httpExchange.fail( + ResponseException("Error sending response with status code ${httpExchange.response.statusCode} for ${LogUtil.describeRequest(httpExchange)}", e) + ) + } + } + future.complete(Unit) + + } catch (e: Exception) { + logger.error( + "Failed to handle response from upstream ${resourceConfig.passthrough} (${request.url}) for ${LogUtil.describeRequest(httpExchange)}", e + ) + future.completeExceptionally(e) + } + } + + private fun handleSoapResponse( + configDir: String, + resourceConfig: PassthroughResourceConfig, + request: Request, + response: Response, + httpExchange: HttpExchange, + future: CompletableFuture, + ) { + try { + if (logger.isTraceEnabled) { + logger.trace("SOAP response from upstream ${resourceConfig.passthrough}: $response") + } + + val body = response.body?.string() ?: "" + logger.debug( + "Received SOAP response from upstream ${resourceConfig.passthrough} (${request.url}) with status ${response.code} [body: ${body.length} bytes] for ${LogUtil.describeRequest(httpExchange)}" + ) + + with(httpExchange.response) { + setStatusCode(response.code) + response.headers.forEach { (name, value) -> + if (name !in skipProxyHeaders) { + putHeader(name, value) + } + } + } + + // Extract SOAPAction header for filename generation + val soapAction = extractSoapAction(httpExchange) + + // Save the request and response with SOAPAction-aware filenames + saveSoapExchange(configDir, httpExchange, body, soapAction) + + responseService.sendThenFinaliseExchange(resourceConfig, httpExchange) { + try { + responseService.writeResponseData( + resourceConfig, + httpExchange, + filenameHintForContentType = null, + Buffer.buffer(body), + template = false, + trustedData = false + ) + } catch (e: Exception) { + httpExchange.fail( + ResponseException("Error sending SOAP response with status code ${httpExchange.response.statusCode} for ${LogUtil.describeRequest(httpExchange)}", e) + ) + } + } + future.complete(Unit) + + } catch (e: Exception) { + logger.error( + "Failed to handle SOAP response from upstream ${resourceConfig.passthrough} (${request.url}) for ${LogUtil.describeRequest(httpExchange)}", e + ) + future.completeExceptionally(e) + } + } + + /** + * Extract the SOAPAction header from the request + */ + fun extractSoapAction(httpExchange: HttpExchange): String? { + // Get SOAPAction header - it might be quoted + val soapAction = httpExchange.request.headers["SOAPAction"] + + // Remove quotes if present and return + return soapAction?.trim('"')?.takeIf { it.isNotEmpty() } + } + + /** + * Generate a filename that incorporates the SOAPAction value + */ + fun generateSoapFilename(baseUrl: String, soapAction: String?, fileType: String): String { + val sanitizedUrl = baseUrl.replace(Regex("[^a-zA-Z0-9]"), "_") + + // If SOAPAction exists, append it to the filename + val sanitizedAction = soapAction?.let { + "_" + it.replace(Regex("[^a-zA-Z0-9]"), "_") + } ?: "" + + return "${sanitizedUrl}${sanitizedAction}.${fileType}" + } + + /** + * Save the SOAP request and response with SOAPAction-aware filenames + */ + fun saveSoapExchange( + configDir: String, + httpExchange: HttpExchange, + responseBody: String, + soapAction: String? + ) { + val requestUrl = httpExchange.request.absoluteUri + val path = URI(requestUrl).path + + // Generate filenames with SOAPAction suffix if present + val requestFilename = generateSoapFilename(path, soapAction, "request.xml") + val responseFilename = generateSoapFilename(path, soapAction, "response.xml") + + val configDirFile = File(configDir) + if (!configDirFile.exists()) { + configDirFile.mkdirs() + } + + // Save request + val requestBodyStr = httpExchange.request.body?.toString() ?: "" + File(configDirFile, requestFilename).writeText(requestBodyStr) + logger.info("Saved SOAP request to: ${requestFilename}") + + // Save response + File(configDirFile, responseFilename).writeText(responseBody) + logger.info("Saved SOAP response to: ${responseFilename}") + + // Generate configuration file that maps this request to its response + generateSoapConfigFile(configDirFile, path, responseFilename, soapAction) + } + + /** + * Generate an Imposter configuration file for this SOAP operation + */ + fun generateSoapConfigFile( + configDir: File, + path: String, + responseFilename: String, + soapAction: String? + ) { + val configFilename = generateSoapFilename(path, soapAction, "config.yaml") + + val configContent = if (soapAction != null) { + """ +plugin: rest +path: $path +resources: + - method: POST + headers: + SOAPAction: "$soapAction" + response: + file: $responseFilename +""".trimIndent() + } else { + """ +plugin: rest +path: $path +resources: + - method: POST + response: + file: $responseFilename +""".trimIndent() + } + + File(configDir, configFilename).writeText(configContent) + logger.info("Generated SOAP configuration file: ${configFilename}") + } + + companion object { + val skipProxyHeaders = listOf( + "Accept-Encoding", + "Host", + + // Hop-by-hop headers. These are removed in requests to the upstream or responses to the client. + // See "13.5.1 End-to-end and Hop-by-hop Headers" in http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html + "Connection", + "Keep-Alive", + "Proxy-Authenticate", + "Proxy-Authorization", + "TE", + "Trailers", + "Transfer-Encoding", + "Upgrade", + ) + } +}