diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3dc4953 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,31 @@ +# Git +.git +.gitignore + +# Documentation +README.md +CHANGELOG.md +docs/ +*.md + +# CI/CD +.github/ +.goreleaser.yml + +# Build artifacts +imposter +*.exe + +# Development files +Makefile +since.yaml + +# Installation scripts +install/ + +# Test files +*_test.go +testdata/ + +# License +LICENSE \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..27305c4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +FROM golang:1.23-alpine AS builder + +WORKDIR /app + +# Copy go mod files first for better caching +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build the binary +RUN go build -tags lambda.norpc -o imposter-cli + +# Final stage +FROM alpine:latest + +RUN apk --no-cache add ca-certificates curl openjdk17-jre docker-cli + +WORKDIR /app + +# Copy the binary from builder stage +COPY --from=builder /app/imposter-cli /usr/local/bin/imposter + +# Create directory for output +RUN mkdir -p /output + +EXPOSE 8080 + +# Default command shows help +CMD ["imposter", "--help"] diff --git a/README.md b/README.md index 848c828..5aaef24 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,10 @@ Example: imposter proxy https://example.com +SOAP 1.1/1.2 example: + + imposter proxy --soap1.1 http://soap-service.example.com/service + Usage: ``` @@ -198,10 +202,12 @@ Flags: --flat Flatten the response file structure -h, --help help for proxy -i, --ignore-duplicate-requests Ignore duplicate requests with same method and URI (default true) + --insecure Skip TLS certificate verification for HTTPS upstream servers -o, --output-dir string Directory in which HTTP exchanges are recorded (default: current working directory) -p, --port int Port on which to listen (default 8080) -H, --response-headers strings Record only these response headers -r, --rewrite-urls Rewrite upstream URL in response body to proxy URL + --soap1.1 Enable SOAP 1.1/1.2 aware mode for capturing requests/responses with action-based differentiation ``` ### Pull engine diff --git a/cmd/proxy.go b/cmd/proxy.go index 287b216..cf5638c 100644 --- a/cmd/proxy.go +++ b/cmd/proxy.go @@ -33,6 +33,8 @@ var proxyFlags = struct { ignoreDuplicateRequests bool recordOnlyResponseHeaders []string flatResponseFileStructure bool + soap11Mode bool + insecure bool }{} // proxyCmd represents the up command @@ -59,6 +61,8 @@ var proxyCmd = &cobra.Command{ IgnoreDuplicateRequests: proxyFlags.ignoreDuplicateRequests, RecordOnlyResponseHeaders: proxyFlags.recordOnlyResponseHeaders, FlatResponseFileStructure: proxyFlags.flatResponseFileStructure, + Soap11Mode: proxyFlags.soap11Mode, + Insecure: proxyFlags.insecure, } proxyUpstream(upstream, proxyFlags.port, outputDir, proxyFlags.rewrite, options) }, @@ -73,6 +77,8 @@ func init() { proxyCmd.Flags().BoolVarP(&proxyFlags.ignoreDuplicateRequests, "ignore-duplicate-requests", "i", true, "Ignore duplicate requests with same method and URI") proxyCmd.Flags().StringSliceVarP(&proxyFlags.recordOnlyResponseHeaders, "response-headers", "H", nil, "Record only these response headers") proxyCmd.Flags().BoolVar(&proxyFlags.flatResponseFileStructure, "flat", false, "Flatten the response file structure") + proxyCmd.Flags().BoolVar(&proxyFlags.soap11Mode, "soap1.1", false, "Enable SOAP 1.1 aware mode for capturing requests/responses with SOAPAction") + proxyCmd.Flags().BoolVar(&proxyFlags.insecure, "insecure", false, "Skip TLS certificate verification for HTTPS upstream servers") rootCmd.AddCommand(proxyCmd) } @@ -88,7 +94,7 @@ func proxyUpstream(upstream string, port int, dir string, rewrite bool, options _, _ = fmt.Fprintf(writer, "ok\n") }) mux.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) { - proxy2.Handle(upstream, writer, request, func(reqBody *[]byte, statusCode int, respBody *[]byte, respHeaders *http.Header) (*[]byte, *http.Header) { + proxy2.Handle(upstream, writer, request, options.Insecure, func(reqBody *[]byte, statusCode int, respBody *[]byte, respHeaders *http.Header) (*[]byte, *http.Header) { if rewrite { respBody = proxy2.Rewrite(respHeaders, respBody, upstream, port) } diff --git a/cmd/proxy_test.go b/cmd/proxy_test.go index 27d7c8e..707e3fe 100644 --- a/cmd/proxy_test.go +++ b/cmd/proxy_test.go @@ -26,6 +26,7 @@ import ( "net/http" "os" "path" + "strings" "testing" "time" ) @@ -61,6 +62,16 @@ func Test_proxyUpstream(t *testing.T) { }, }, }, + { + name: "proxy SOAP 1.1 service with SOAPAction", + args: args{ + rewrite: false, + options: proxy.RecorderOptions{ + FlatResponseFileStructure: false, + Soap11Mode: true, + }, + }, + }, } for _, tt := range tests { server, upstream, upstreamPort, err := startUpstream() @@ -85,14 +96,29 @@ func Test_proxyUpstream(t *testing.T) { t.Fatalf("proxy did not come up on port %d", port) } - if err := sendRequestToProxy(port); err != nil { - t.Fatal(err) + // Send appropriate request based on test mode + if tt.args.options.Soap11Mode { + if err := sendSoapRequestToProxy(port); err != nil { + t.Fatal(err) + } + } else { + if err := sendRequestToProxy(port); err != nil { + t.Fatal(err) + } } upstreamHostAndPort := fmt.Sprintf("localhost-%d", upstreamPort) cfgFileName := upstreamHostAndPort + "-config.yaml" var indexFileName string - if tt.args.options.FlatResponseFileStructure { + + if tt.args.options.Soap11Mode { + // SOAP mode: expect SOAPAction in filename + if tt.args.options.FlatResponseFileStructure { + indexFileName = upstreamHostAndPort + "-POST-index_http___example_com_service_GetUser.txt" + } else { + indexFileName = "POST-index_http___example_com_service_GetUser.txt" + } + } else if tt.args.options.FlatResponseFileStructure { indexFileName = upstreamHostAndPort + "-GET-index.txt" } else { indexFileName = "GET-index.txt" @@ -156,3 +182,41 @@ func sendRequestToProxy(port int) error { } return nil } + +func sendSoapRequestToProxy(port int) error { + client := http.Client{ + Timeout: 2 * time.Second, + } + url := fmt.Sprintf("http://localhost:%d", port) + + soapBody := ` + + + + 123 + + +` + + req, err := http.NewRequest("POST", url, strings.NewReader(soapBody)) + if err != nil { + return fmt.Errorf("failed to create SOAP request: %s", err) + } + + req.Header.Set("Content-Type", "text/xml; charset=utf-8") + req.Header.Set("SOAPAction", "http://example.com/service/GetUser") + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("SOAP request failed for proxy at %s: %s", url, err) + } + if _, err := io.ReadAll(resp.Body); err != nil { + return fmt.Errorf("body read failed for proxy at %s: %s", url, err) + } + _ = resp.Body.Close() + if resp.StatusCode == 200 { + logger.Tracef("SOAP proxy up at %s", url) + return nil + } + return nil +} diff --git a/docs/soap_proxy.md b/docs/soap_proxy.md new file mode 100644 index 0000000..1f595bc --- /dev/null +++ b/docs/soap_proxy.md @@ -0,0 +1,132 @@ +# SOAP 1.1/1.2 Proxy Support + +The imposter-cli proxy command supports both SOAP 1.1 and SOAP 1.2 services with the `--soap1.1` flag, enabling action-aware recording and mock generation. + +## Usage + +```bash +imposter proxy --soap1.1 [URL] [flags] +``` + +### Flags + +- `--soap1.1` - Enable SOAP 1.1/1.2 aware mode for capturing requests/responses with action-based differentiation +- `--insecure` - Skip TLS certificate verification for HTTPS upstream servers (useful for self-signed certificates) + +### Example + +```bash +imposter proxy --soap1.1 http://soap-service.example.com/service +``` + +For HTTPS services with self-signed certificates: + +```bash +imposter proxy --soap1.1 --insecure https://soap-service.example.com/service +``` + +## Features + +When `--soap1.1` is enabled: + +1. **SOAP Action Detection** - Supports both SOAP 1.1 and SOAP 1.2 action specifications: + - **SOAP 1.1**: SOAPAction header (`SOAPAction: "http://example.com/GetUser"`) + - **SOAP 1.2**: Content-Type action parameter (`Content-Type: application/soap+xml;action="http://example.com/GetUser"`) + +2. **Operation-Specific File Naming** - Files include action in names for same-endpoint differentiation + +3. **Automatic Configuration** - Generated configs include proper header matching based on SOAP version + +## Action Detection + +### SOAP 1.1 Style (SOAPAction Header) +```http +POST /service HTTP/1.1 +Content-Type: text/xml; charset=utf-8 +SOAPAction: "http://example.com/GetUser" +``` + +### SOAP 1.2 Style (Content-Type Action Parameter) +```http +POST /service HTTP/1.1 +Content-Type: application/soap+xml;charset=UTF-8;action="http://example.com/GetUser" +``` + +## File Naming + +- Standard: `POST-endpoint.xml` +- SOAP 1.1/1.2: `POST-endpoint_http___example_com_GetUser.xml` (for action "http://example.com/GetUser") + +## Generated Configuration + +### SOAP 1.1 Configuration +```yaml +plugin: rest +path: /service +resources: + - method: POST + requestHeaders: + SOAPAction: "http://example.com/GetUser" + response: + file: POST-endpoint_http___example_com_GetUser.xml +``` + +### SOAP 1.2 Configuration +```yaml +plugin: rest +path: /service +resources: + - method: POST + requestHeaders: + Content-Type: "application/soap+xml;charset=UTF-8;action=\"http://example.com/GetUser\"" + response: + file: POST-endpoint_http___example_com_GetUser.xml +``` + +## Docker Usage + +Run the proxy in a Docker container: + +```bash +# Basic usage +docker run -d --name imposter-soap-proxy -p 8080:8080 -v $PWD:/output \ + nexus.bcn.crealogix.net:18080/imposter-cli-soap11:latest \ + proxy --soap1.1 --capture-request-body --capture-request-headers --output-dir /output https://soap-service.example.com/service + +# For HTTPS with self-signed certificates +docker run -d --name imposter-soap-proxy -p 8080:8080 -v $PWD:/output \ + nexus.bcn.crealogix.net:18080/imposter-cli-soap11:latest \ + proxy --soap1.1 --insecure --capture-request-body --capture-request-headers --output-dir /output https://soap-service.example.com/service +``` + +### Docker Container Notes + +The `nexus.bcn.crealogix.net:18080/imposter-cli-soap11:latest` image includes: + +- **Full engine support**: Supports `-t docker`, `-t jvm`, and `-t golang` engine types +- **Java Runtime**: OpenJDK 17 for JVM engine operations +- **Docker Client**: For Docker engine operations (requires socket mapping) +- **SOAP 1.1/1.2**: Enhanced proxy functionality for SOAP services + +**Engine Usage Examples:** + +```bash +# Using JVM engine (recommended) +docker run --rm -v $PWD:/config -p 8080:8080 \ + nexus.bcn.crealogix.net:18080/imposter-cli-soap11:latest \ + up -t jvm /config + +# Using Docker engine (requires socket mapping and privileges) +docker run --rm --privileged -v /var/run/docker.sock:/var/run/docker.sock \ + -v $PWD:/config -p 8080:8080 \ + nexus.bcn.crealogix.net:18080/imposter-cli-soap11:latest \ + up -t docker /config +``` + +## Compatibility + +- **SOAP 1.1**: Full support via SOAPAction header +- **SOAP 1.2**: Full support via Content-Type action parameter +- **Mixed environments**: Automatically detects and handles both formats +- **Backward compatibility**: Maintains full compatibility with existing proxy functionality +- **Enterprise HTTPS**: Supports self-signed certificates with `--insecure` flag diff --git a/internal/proxy/content.go b/internal/proxy/content.go index 29316f2..12aa7f1 100644 --- a/internal/proxy/content.go +++ b/internal/proxy/content.go @@ -12,6 +12,7 @@ var textMediaTypes = []string{ "application/javascript", "application/json", "application/xml", + "application/soap\\+xml", "application/x-www-form-urlencoded", } diff --git a/internal/proxy/files.go b/internal/proxy/files.go index 55314b9..7dd5534 100644 --- a/internal/proxy/files.go +++ b/internal/proxy/files.go @@ -27,6 +27,52 @@ import ( "strings" ) +// extractSoapAction extracts the SOAPAction header from the request or Content-Type action parameter +func extractSoapAction(req *http.Request) string { + // First try the SOAPAction header (SOAP 1.1) + soapAction := req.Header.Get("SOAPAction") + logger.Debugf("SOAPAction header raw value: '%s'", soapAction) + + if soapAction == "" { + // Try Content-Type action parameter (SOAP 1.2) + contentType := req.Header.Get("Content-Type") + logger.Debugf("Content-Type header: '%s'", contentType) + + if strings.Contains(contentType, "action=") { + // Extract action parameter from Content-Type + parts := strings.Split(contentType, "action=") + if len(parts) > 1 { + actionPart := strings.TrimSpace(parts[1]) + // Handle both quoted and unquoted action values + if strings.HasPrefix(actionPart, "\"") { + // Find closing quote + if endQuote := strings.Index(actionPart[1:], "\""); endQuote != -1 { + soapAction = actionPart[1 : endQuote+1] + } + } else { + // Take until semicolon or end of string + if semicolon := strings.Index(actionPart, ";"); semicolon != -1 { + soapAction = actionPart[:semicolon] + } else { + soapAction = actionPart + } + } + logger.Debugf("Extracted action from Content-Type: '%s'", soapAction) + } + } + } + + if soapAction == "" { + logger.Debugf("No SOAPAction header or Content-Type action found in request") + return "" + } + + // Remove quotes if present + soapAction = strings.Trim(soapAction, "\"") + logger.Debugf("SOAPAction after processing: '%s'", soapAction) + return soapAction +} + // generateRespFileName returns a unique filename for the given response func generateRespFileName( upstreamHost string, @@ -54,14 +100,48 @@ func generateRespFileName( flatParent += "_" } parentDir = dir - respFileName = upstreamHost + "-" + req.Method + "-" + flatParent + baseFileName + + if options.Soap11Mode { + logger.Debugf("SOAP 1.1 mode enabled - checking for SOAPAction header") + // SOAP mode: use SOAPAction in filename + soapAction := extractSoapAction(req) + if soapAction != "" { + logger.Debugf("Found SOAPAction: '%s', generating SOAP-aware filename", soapAction) + // Sanitize SOAPAction for filename - replace all non-alphanumeric with underscore + sanitizedAction := strings.ReplaceAll(soapAction, "/", "_") + sanitizedAction = strings.ReplaceAll(sanitizedAction, ":", "_") + sanitizedAction = strings.ReplaceAll(sanitizedAction, ".", "_") + respFileName = upstreamHost + "-" + req.Method + "-" + flatParent + baseFileName + "_" + sanitizedAction + logger.Debugf("Generated SOAP filename: '%s'", respFileName) + } else { + logger.Debugf("No SOAPAction found, using standard filename") + respFileName = upstreamHost + "-" + req.Method + "-" + flatParent + baseFileName + } + } else { + respFileName = upstreamHost + "-" + req.Method + "-" + flatParent + baseFileName + } } else { parentDir = path.Join(dir, sanitisedParent) if err := ensureDirExists(parentDir); err != nil { return "", err } - respFileName = req.Method + "-" + baseFileName + + if options.Soap11Mode { + // SOAP mode: use SOAPAction in filename + soapAction := extractSoapAction(req) + if soapAction != "" { + // Sanitize SOAPAction for filename - replace all non-alphanumeric with underscore + sanitizedAction := strings.ReplaceAll(soapAction, "/", "_") + sanitizedAction = strings.ReplaceAll(sanitizedAction, ":", "_") + sanitizedAction = strings.ReplaceAll(sanitizedAction, ".", "_") + respFileName = req.Method + "-" + baseFileName + "_" + sanitizedAction + } else { + respFileName = req.Method + "-" + baseFileName + } + } else { + respFileName = req.Method + "-" + baseFileName + } } var suffix string diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 6648afa..b846d96 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -18,6 +18,7 @@ package proxy import ( "bytes" + "crypto/tls" "fmt" "gatehill.io/imposter/internal/logging" "gatehill.io/imposter/internal/stringutil" @@ -66,21 +67,23 @@ var skipRecordHeaders = []string{ var logger = logging.GetLogger() -var transport *http.Transport - -func init() { - transport = &http.Transport{ +func createTransport(insecure bool) *http.Transport { + transport := &http.Transport{ DisableCompression: true, MaxIdleConns: viper.GetInt("proxy.maxIdleConns"), IdleConnTimeout: viper.GetDuration("proxy.idleConnTimeout"), } - logger.Tracef("initialised proxy transport: %+v", transport) + if insecure { + transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + } + return transport } func Handle( upstream string, w http.ResponseWriter, req *http.Request, + insecure bool, listener func(reqBody *[]byte, statusCode int, respBody *[]byte, respHeaders *http.Header) (*[]byte, *http.Header), ) { startTime := time.Now() @@ -95,7 +98,7 @@ func Handle( return } - statusCode, responseBody, respHeaders, err := forward(upstream, req.Method, path, queryString, clientReqHeaders, requestBody) + statusCode, responseBody, respHeaders, err := forward(upstream, req.Method, path, queryString, clientReqHeaders, requestBody, insecure) if err != nil { logger.Error(err) w.WriteHeader(http.StatusBadGateway) @@ -131,6 +134,7 @@ func forward( queryString string, clientRequestHeaders *http.Header, requestBody *[]byte, + insecure bool, ) (statusCode int, responseBody *[]byte, upstreamRespHeaders *http.Header, err error) { logger.Debugf("invoking upstream %s with %s %s [body: %v bytes]", upstream, httpMethod, path, len(*requestBody)) @@ -147,7 +151,7 @@ func forward( upstreamReqHeaders := req.Header copyHeaders(clientRequestHeaders, &upstreamReqHeaders) - client := &http.Client{Transport: transport} + client := &http.Client{Transport: createTransport(insecure)} resp, err := client.Do(req) if err != nil { return 0, nil, nil, err diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go index 82ebfe9..1f807a5 100644 --- a/internal/proxy/proxy_test.go +++ b/internal/proxy/proxy_test.go @@ -172,7 +172,7 @@ func TestHandle(t *testing.T) { } // Call the Handle function with our test server as upstream - Handle(server.URL, rr, req, listenerFn) + Handle(server.URL, rr, req, false, listenerFn) // Verify status code if capturedStatusCode != tc.statusCode { @@ -268,7 +268,7 @@ func TestHandleEndToEnd(t *testing.T) { w.Write([]byte(`{"status":"OK"}`)) }) proxyMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - Handle(upstreamURL, w, r, func(reqBody *[]byte, statusCode int, respBody *[]byte, respHeaders *http.Header) (*[]byte, *http.Header) { + Handle(upstreamURL, w, r, false, func(reqBody *[]byte, statusCode int, respBody *[]byte, respHeaders *http.Header) (*[]byte, *http.Header) { // Pass through unchanged return respBody, respHeaders }) diff --git a/internal/proxy/recorder.go b/internal/proxy/recorder.go index 5d6a8a0..ec59da7 100644 --- a/internal/proxy/recorder.go +++ b/internal/proxy/recorder.go @@ -36,6 +36,8 @@ type RecorderOptions struct { IgnoreDuplicateRequests bool RecordOnlyResponseHeaders []string FlatResponseFileStructure bool + Soap11Mode bool + Insecure bool } func StartRecorder(upstream string, dir string, options RecorderOptions) (chan HttpExchange, error) { @@ -60,7 +62,16 @@ func StartRecorder(upstream string, dir string, options RecorderOptions) (chan H exchange := <-recordC var responseFilePrefix string - requestHash := getRequestHash(exchange.Request) + var requestHash string + + if options.Soap11Mode { + // In SOAP mode, use SOAPAction + path for uniqueness + soapAction := extractSoapAction(exchange.Request) + requestHash = getRequestHash(exchange.Request) + "_" + soapAction + } else { + requestHash = getRequestHash(exchange.Request) + } + if stringutil.Contains(requestHashes, requestHash) { if options.IgnoreDuplicateRequests { logger.Debugf("skipping recording of duplicate request %s %v", exchange.Request.Method, exchange.Request.URL) @@ -204,6 +215,25 @@ func buildResource( } } resource.RequestHeaders = &headers + } else if options.Soap11Mode { + // In SOAP mode, capture the header that contains the action for matching + soapAction := extractSoapAction(&req) + if soapAction != "" { + headers := make(map[string]string) + + // Check if action came from SOAPAction header or Content-Type + if req.Header.Get("SOAPAction") != "" { + // SOAP 1.1 style - use SOAPAction header + headers["SOAPAction"] = soapAction + } else { + // SOAP 1.2 style - use Content-Type header + contentType := req.Header.Get("Content-Type") + if contentType != "" { + headers["Content-Type"] = contentType + } + } + resource.RequestHeaders = &headers + } } if options.CaptureRequestBody && exchange.RequestBody != nil { contentType := req.Header.Get("Content-Type") @@ -252,3 +282,18 @@ func updateConfigFile(exchange HttpExchange, options impostermodel2.ConfigGenera logger.Debugf("wrote config file %s for %s %v", configFile, req.Method, req.URL) return nil } + +// generateSoapFilename generates a filename that incorporates the SOAPAction value +func generateSoapFilename(basePath string, soapAction string, fileType string) string { + sanitizedPath := strings.ReplaceAll(basePath, "/", "_") + sanitizedPath = strings.ReplaceAll(sanitizedPath, "\\", "_") + + if soapAction != "" { + // Sanitize SOAPAction for filename + sanitizedAction := strings.ReplaceAll(soapAction, "/", "_") + sanitizedAction = strings.ReplaceAll(sanitizedAction, "\\", "_") + sanitizedAction = strings.ReplaceAll(sanitizedAction, ":", "_") + return fmt.Sprintf("%s_%s.%s", sanitizedPath, sanitizedAction, fileType) + } + return fmt.Sprintf("%s.%s", sanitizedPath, fileType) +}