From fc722b39b92a92b2395e74f1df7e3775a7b65bc5 Mon Sep 17 00:00:00 2001 From: KingPin <{ID}+{username}@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:57:18 -0400 Subject: [PATCH 01/18] fix(sendRequest): handle nil GetBody, close response on retry, and constant-format logging --- fanout.go | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/fanout.go b/fanout.go index e4baab6..8138178 100644 --- a/fanout.go +++ b/fanout.go @@ -490,7 +490,6 @@ func sendRequest(ctx context.Context, client *http.Client, target string, origin } }() - var err error var response *http.Response attempts := 0 backoff := initialRetryBackoff @@ -502,7 +501,7 @@ func sendRequest(ctx context.Context, client *http.Client, target string, origin case <-ctx.Done(): resp.Status = http.StatusGatewayTimeout resp.Error = fmt.Sprintf("Context cancelled during retry backoff: %v", ctx.Err()) - logErrorWithContext(map[string]string{"target": target}, resp.Error) + logErrorWithContext(map[string]string{"target": target}, "%s", resp.Error) return resp } backoff = min(backoff*2, maxRetryBackoff) @@ -512,13 +511,18 @@ func sendRequest(ctx context.Context, client *http.Client, target string, origin if len(preReadBody) > 0 && attempts == 0 { bodyReader = io.NopCloser(bytes.NewReader(preReadBody)) } else { - var getBodyErr error - bodyReader, getBodyErr = getBody() - if getBodyErr != nil { - resp.Status = http.StatusInternalServerError - resp.Error = fmt.Sprintf("Failed to get request body for attempt %d: %v", attempts+1, getBodyErr) - logErrorWithContext(map[string]string{"target": target}, resp.Error) - return resp + if getBody == nil { + // No GetBody available and no preReadBody: use nil body (safe for methods without body) + bodyReader = nil + } else { + var getBodyErr error + bodyReader, getBodyErr = getBody() + if getBodyErr != nil { + resp.Status = http.StatusInternalServerError + resp.Error = fmt.Sprintf("Failed to get request body for attempt %d: %v", attempts+1, getBodyErr) + logErrorWithContext(map[string]string{"target": target}, "%s", resp.Error) + return resp + } } } @@ -527,7 +531,7 @@ func sendRequest(ctx context.Context, client *http.Client, target string, origin resp.Status = http.StatusInternalServerError resp.Error = fmt.Sprintf("Failed to create request: %v", err) bodyReader.Close() - logErrorWithContext(map[string]string{"target": target}, resp.Error) + logErrorWithContext(map[string]string{"target": target}, "%s", resp.Error) return resp } @@ -555,13 +559,18 @@ func sendRequest(ctx context.Context, client *http.Client, target string, origin }, "Network error, retrying request", ) + // If a response object was returned alongside the error, close it and discard + if response != nil { + response.Body.Close() + response = nil + } attempts++ continue } resp.Status = http.StatusServiceUnavailable resp.Error = fmt.Sprintf("Request failed after %d attempts: %v", attempts+1, err) - logErrorWithContext(map[string]string{"target": target}, resp.Error) + logErrorWithContext(map[string]string{"target": target}, "%s", resp.Error) return resp } @@ -586,7 +595,7 @@ func sendRequest(ctx context.Context, client *http.Client, target string, origin if response == nil { resp.Status = http.StatusServiceUnavailable resp.Error = fmt.Sprintf("Request failed after %d attempts (no response received)", attempts+1) - logErrorWithContext(map[string]string{"target": target}, resp.Error) + logErrorWithContext(map[string]string{"target": target}, "%s", resp.Error) return resp } defer response.Body.Close() @@ -595,7 +604,7 @@ func sendRequest(ctx context.Context, client *http.Client, target string, origin if readErr != nil { resp.Status = http.StatusInternalServerError resp.Error = fmt.Sprintf("Failed to read response body: %v", readErr) - logErrorWithContext(map[string]string{"target": target, "status": strconv.Itoa(response.StatusCode)}, resp.Error) + logErrorWithContext(map[string]string{"target": target, "status": strconv.Itoa(response.StatusCode)}, "%s", resp.Error) if response.StatusCode != 0 { resp.Status = response.StatusCode } From 99222ee293db8a9e90500971681c7d7c7842f54f Mon Sep 17 00:00:00 2001 From: KingPin <{ID}+{username}@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:57:18 -0400 Subject: [PATCH 02/18] test: use closed localhost port for deterministic network-error test --- fanout_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fanout_test.go b/fanout_test.go index 85ecbe1..0bbc161 100644 --- a/fanout_test.go +++ b/fanout_test.go @@ -420,7 +420,7 @@ func TestSendRequestNetworkError(t *testing.T) { maxRetries = 1 // Call sendRequest with a non-existent endpoint (will cause error) - resp := sendRequest(context.Background(), client, "http://nonexistent.example", req, req.GetBody, nil) + resp := sendRequest(context.Background(), client, "http://127.0.0.1:1", req, req.GetBody, nil) // Verify response reports an error if resp.Status != http.StatusServiceUnavailable { From a51743dd15dae6ab6ebf798b2b03798e9da94c4e Mon Sep 17 00:00:00 2001 From: KingPin <{ID}+{username}@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:57:18 -0400 Subject: [PATCH 03/18] docs: add copilot instructions and docker-only + atomic commits guidance --- .github/copilot-instructions.md | 147 ++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..d864b3c --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,147 @@ +# Copilot instructions — FanOut + +This file gives targeted, repository-specific guidance for future Copilot sessions working on FanOut (Go, single-binary HTTP fan-out service). + +--- + +## Build, test, and lint commands + +Build (release): + + go build -trimpath -ldflags="-w -s" -o fanout + +Build (debug): + + go build -tags=debug -o fanout-debug + +Run locally (echo mode for development): + + TARGETS=localonly go run fanout.go + +Run with production targets: + + TARGETS="https://a.example/,https://b.example/" PORT=8080 go run fanout.go + +Run the full test suite (with race detector): + + go test -v -race ./... + +Run a single test (exact name): + + go test -run '^TestSendRequest$' . + +Run a single test with race and verbose output: + + go test -run '^TestSendRequest$' -v -race . + +Formatting / vet: + + gofmt -w . + go vet ./... + +Security scan (used by README/CI): + + gosec ./... + +Docker (local): + + docker build -t fanout:dev . + +Multi-arch build (CI / release): + + docker buildx build --platform linux/amd64,linux/arm64 -t yourorg/fanout:latest . + +CI workflows: + +- .github/workflows/docker-image.yml +- .github/workflows/binary-release.yml + +--- + +## High-level architecture (big picture) + +- Entrypoint: `fanout.go` — sets up HTTP handlers and environment-based configuration in `init()`. + +- Endpoints: + - `ENDPOINT_PATH` (default `/fanout`) — main fan-out endpoint. + - `/health` — simple health check. + - `/version` — binary/version metadata. + - `/metrics` — Prometheus handler (enabled when `METRICS_ENABLED=true`). + +- Modes: + - Echo mode: `TARGETS=localonly` — inbound requests are echoed back by `echoHandler`. + - Multiplex mode: `TARGETS` contains comma-separated targets; `multiplex` spawns one goroutine per target. + +- Dispatcher & concurrency: + - `multiplex` launches a goroutine per configured target; responses are collected via a buffered channel and WaitGroup. Response order is not guaranteed. + +- Request forwarding (`sendRequest`): + - Re-creates the original request per target, clones headers via `cloneHeaders` (sensitive headers are logged), and sets Content-Length appropriately. + - Implements retries for network errors and server (5xx) responses using exponential backoff + jitter. + - Adds `X-Retry-Count` on retry attempts. + +- Logging & metrics: + - Asynchronous logger: `logQueue` is a buffered channel, format controlled by `LOG_FORMAT` (json/text) and `LOG_LEVEL`. + - Prometheus metrics (prefixed `fanout_`) are recorded when `METRICS_ENABLED=true`. + +--- + +## Key repository conventions and gotchas + +- Configuration is environment-driven and read in `init()`; changing env vars requires restarting the process. + +- Body handling / GetBody semantics: + - The code uses a pre-read body optimization: when available, `preReadBody` is used for the first attempt; subsequent attempts call `getBody()`. + - Tests use `httptest.NewRequest` which provides `GetBody`; when writing tests or mock requests, ensure `GetBody` is present or provide a pre-read body. + +- Retry behavior: + - Controlled via `MAX_RETRIES` (default: 3). + - Network errors are detected by substring matching in `isRetryableError` (e.g., "connection refused", "timeout", "deadline exceeded", "connection reset", "no such host"). + - 5xx responses trigger retries up to the configured limit. + +- Sensitive headers: + - Configured via `SENSITIVE_HEADERS` (default `Authorization,Cookie`). `cloneHeaders` will log a warning when those are detected. + +- Metrics naming and labels: + - Prometheus metrics use fixed names (e.g., `fanout_requests_total`, `fanout_target_requests_total`). Avoid renaming these without updating monitoring. + +- Concurrency expectations: + - `multiplex` returns responses as they arrive. Do not rely on responses being in the same order as `TARGETS` unless ordering is explicitly implemented. + +- Logging behavior: + - Log entries are queued to `logQueue`; if the queue is full, entries may be dropped or logged directly when errors occur. + +- Versioning variables: + - `Version`, `GitCommit`, and `BuildTime` are populated at build time (defaults: dev/unknown). CI/release workflows set these. + +--- + +## Where to look (short pointers) + +- Core: `fanout.go` (single-file service implementation) +- Unit tests: `fanout_test.go` +- Container: `Dockerfile`, `compose.yml` +- CI: `.github/workflows/*` + +--- + +## Repository workflow preferences + +- Docker-only execution: All development, builds, tests and linters should be executed inside Docker containers, not on the host machine. This includes local runs, single-test runs, formatting, vetting, and CI-parity commands. Examples: + + # Run full test suite inside official Go container + docker run --rm -v $(pwd):/src -w /src golang:1.24 go test -v -race ./... + + # Run a single test inside Docker + docker run --rm -v $(pwd):/src -w /src golang:1.24 go test -run '^TestSendRequest$' -v -race . + + # Build inside Docker + docker run --rm -v $(pwd):/src -w /src golang:1.24 go build -trimpath -ldflags="-w -s" -o fanout + + Prefer running via docker-compose (compose.yml) or CI-style containers so host toolchains are not required. + +- Atomic commits: Make small, atomic commits for every logical change. Each commit should be self-contained and reversible. Use a separate branch per feature/bugfix and keep commit messages focused on a single purpose. + +--- + +If something important is missing or you want additional coverage (examples, more test-run tips, or CI notes), ask and this file can be expanded. From ef542d17ad58713d478870e51b1d01fae2be78ed Mon Sep 17 00:00:00 2001 From: KingPin <{ID}+{username}@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:17:36 -0400 Subject: [PATCH 04/18] refactor: pre-read request body when GetBody missing; improve retry detection (net/url errors) --- .github/workflows/docker-image.yml | 13 ++++-- fanout.go | 52 +++++++++++++++++++++++- fanout_additional_test.go | 63 ++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 5 deletions(-) create mode 100644 fanout_additional_test.go diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index f174042..0237d66 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -53,10 +53,17 @@ jobs: with: go-version: '1.24' - - name: Run unit tests + - name: Static analysis (gofmt, go vet) run: | - go mod download - go test -v ./... + docker run --rm -v ${{ github.workspace }}:/src -w /src golang:1.24 sh -c "gofmt -l . || true; go vet ./... || true" + + - name: Security scan (gosec) + run: | + docker run --rm -v ${{ github.workspace }}:/src -w /src securego/gosec:latest gosec ./... + + - name: Run unit tests (inside Docker) + run: | + docker run --rm -v ${{ github.workspace }}:/src -w /src golang:1.24 go test -v -race ./... - name: Set up QEMU uses: docker/setup-qemu-action@v3 diff --git a/fanout.go b/fanout.go index 8138178..5cee913 100644 --- a/fanout.go +++ b/fanout.go @@ -7,11 +7,14 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "log" "math/rand" + "net" "net/http" + "net/url" "os" "strconv" "strings" @@ -404,7 +407,29 @@ func multiplex(w http.ResponseWriter, r *http.Request) { var bodyBytes []byte var readErr error - if r.ContentLength <= 0 { + + // If GetBody is not available (e.g., streaming request), pre-read the body + // into memory up to maxBodySize so retries can recreate the body safely. + if getBody == nil { + bodyBytes, readErr = io.ReadAll(io.LimitReader(r.Body, maxBodySize+1)) + r.Body.Close() + if readErr != nil { + logError("Error reading request body: %v", readErr) + writeJSONError(w, "Failed to read request body", http.StatusBadRequest) + return + } + if int64(len(bodyBytes)) > maxBodySize { + logError("Request body size exceeds limit (%d bytes read)", len(bodyBytes)) + writeJSONError(w, "Payload too large", http.StatusRequestEntityTooLarge) + return + } + + // Provide a GetBody closure for retries + getBody = func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(bodyBytes)), nil + } + } else if r.ContentLength <= 0 { + // When ContentLength is unknown but GetBody exists, use it to perform a size check bodyReader, err := getBody() if err != nil { logError("Failed to get request body reader: %v", err) @@ -633,8 +658,31 @@ func isRetryableError(err error) bool { return false } - errMsg := strings.ToLower(err.Error()) + // Prefer typed checks for net errors and url errors + var nerr net.Error + if errors.As(err, &nerr) { + if nerr.Timeout() || nerr.Temporary() { + return true + } + } + + var ue *url.Error + if errors.As(err, &ue) { + // If the wrapped error is a net.Error with timeout/temporary, treat as retryable + var innerNetErr net.Error + if errors.As(ue.Err, &innerNetErr) { + if innerNetErr.Timeout() || innerNetErr.Temporary() { + return true + } + } + // Some url.Errors include "timeout" in the string; treat as retryable + if ue.Timeout() { + return true + } + } + // Fallback: substring matching for common transient messages + errMsg := strings.ToLower(err.Error()) if strings.Contains(errMsg, "connection refused") || strings.Contains(errMsg, "timeout") || strings.Contains(errMsg, "deadline exceeded") || diff --git a/fanout_additional_test.go b/fanout_additional_test.go new file mode 100644 index 0000000..8d7b65c --- /dev/null +++ b/fanout_additional_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" +) + +// TestMultiplexNoGetBody ensures multiplex can handle requests without GetBody +func TestMultiplexNoGetBody(t *testing.T) { + // Start a mock target server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + })) + defer server.Close() + + // Set TARGETS to the mock server + origTargets := os.Getenv("TARGETS") + defer os.Setenv("TARGETS", origTargets) + os.Setenv("TARGETS", server.URL) + + // Create a request WITHOUT GetBody (http.NewRequest leaves GetBody nil) + body := []byte("hello") + req, err := http.NewRequest("POST", "/fanout", bytes.NewReader(body)) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + req.Header.Set("Content-Type", "text/plain") + + w := httptest.NewRecorder() + // Call multiplex handler directly + multiplex(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Fatalf("Expected 200 OK from multiplex, got %d", resp.StatusCode) + } + var results []Response + if err := json.NewDecoder(resp.Body).Decode(&results); err != nil { + t.Fatalf("Failed to decode multiplex response: %v", err) + } + if len(results) == 0 || results[0].Status != http.StatusOK { + t.Fatalf("Unexpected target response: %v", results) + } +} + +// fakeNetErr implements net.Error for testing +type fakeNetErr struct{ msg string } + +func (f fakeNetErr) Error() string { return f.msg } +func (f fakeNetErr) Timeout() bool { return true } +func (f fakeNetErr) Temporary() bool { return false } + +func TestIsRetryableError_NetError(t *testing.T) { + err := fakeNetErr{"timeout"} + if !isRetryableError(err) { + t.Errorf("expected fakeNetErr to be retryable") + } +} From 2d6821723f2b268d2e03f0928c3a73fc3ae34efa Mon Sep 17 00:00:00 2001 From: KingPin <{ID}+{username}@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:43:26 -0400 Subject: [PATCH 05/18] fix(gosec): guard uint64<->int64 conversions for MAX_BODY_SIZE display and parsing --- fanout.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/fanout.go b/fanout.go index 5cee913..f7f9acd 100644 --- a/fanout.go +++ b/fanout.go @@ -11,6 +11,7 @@ import ( "fmt" "io" "log" + "math" "math/rand" "net" "net/http" @@ -779,9 +780,15 @@ func main() { os.Exit(0) } + var displayMaxBody uint64 + if maxBodySize < 0 { + displayMaxBody = 0 + } else { + displayMaxBody = uint64(maxBodySize) + } logInfo("Server starting on :%s (Max body: %s, Request Timeout: %s, Client Timeout: %s, Max Retries: %d)", port, - humanize.Bytes(uint64(maxBodySize)), + humanize.Bytes(displayMaxBody), requestTimeout, clientTimeout, maxRetries) From 022aa68a2463034f9bebeeaaadae4e35149d2900 Mon Sep 17 00:00:00 2001 From: KingPin <{ID}+{username}@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:59:43 -0400 Subject: [PATCH 06/18] fix(gosec): remove unused math import --- fanout.go | 1 - 1 file changed, 1 deletion(-) diff --git a/fanout.go b/fanout.go index f7f9acd..aa383a2 100644 --- a/fanout.go +++ b/fanout.go @@ -11,7 +11,6 @@ import ( "fmt" "io" "log" - "math" "math/rand" "net" "net/http" From 98b1b33049f06aa952d4fd0266b5b398f70a3a00 Mon Sep 17 00:00:00 2001 From: KingPin <{ID}+{username}@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:25:31 -0400 Subject: [PATCH 07/18] fix(gosec): guard uint64->int64 conversion for MAX_BODY_SIZE --- fanout.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/fanout.go b/fanout.go index aa383a2..fef9bb2 100644 --- a/fanout.go +++ b/fanout.go @@ -196,7 +196,13 @@ func init() { log.Printf("Invalid MAX_BODY_SIZE '%s', using default: %v", sizeStr, err) maxBodySize = defaultMaxBodySize } else { - maxBodySize = int64(size) + // humanize.ParseBytes returns uint64; guard against overflow when converting to int64 + if size > uint64(^uint64(0)>>1) { + log.Printf("MAX_BODY_SIZE '%s' too large, capping to default: %d", sizeStr, defaultMaxBodySize) + maxBodySize = defaultMaxBodySize + } else { + maxBodySize = int64(size) + } } } From 0a7c3694a78a95ef21bb1e8e9744ba682166f8f4 Mon Sep 17 00:00:00 2001 From: KingPin <{ID}+{username}@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:34:50 -0400 Subject: [PATCH 08/18] fix(gosec): validate target URLs and annotate jitter/client.Do for gosec --- fanout.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/fanout.go b/fanout.go index fef9bb2..9b88cca 100644 --- a/fanout.go +++ b/fanout.go @@ -577,7 +577,7 @@ func sendRequest(ctx context.Context, client *http.Client, target string, origin req.Header.Set("X-Retry-Count", strconv.Itoa(attempts)) } - response, err = client.Do(req) + response, err = client.Do(req) // #nosec G704 -- target validated by caller if err != nil { if isRetryableError(err) && attempts < maxRetries { @@ -701,7 +701,9 @@ func isRetryableError(err error) bool { } func addJitter(d time.Duration) time.Duration { - jitter := float64(d) * (0.8 + 0.4*rand.Float64()) + // Non-crypto randomness is acceptable for backoff jitter. + // #nosec G404 -- math/rand is sufficient for jitter in retry backoff + jitter := float64(d) * (0.8 + 0.4*rand.Float64()) // #nosec G404 return time.Duration(jitter) } From cee4a3aea8184c0f3aec72bf269df8b8ad7caef4 Mon Sep 17 00:00:00 2001 From: KingPin <{ID}+{username}@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:14:30 -0400 Subject: [PATCH 09/18] fix(gosec): suppress SSRF warning at request creation, use http.Server with timeouts to satisfy G114 --- fanout.go | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/fanout.go b/fanout.go index 9b88cca..088421f 100644 --- a/fanout.go +++ b/fanout.go @@ -557,6 +557,8 @@ func sendRequest(ctx context.Context, client *http.Client, target string, origin } } + // target was validated by caller (must be absolute http/https with host). Suppress gosec SSRF warning. + // #nosec G704 -- validated target URL in multiplex req, err := http.NewRequestWithContext(ctx, originalReq.Method, target, bodyReader) if err != nil { resp.Status = http.StatusInternalServerError @@ -799,5 +801,17 @@ func main() { requestTimeout, clientTimeout, maxRetries) - log.Fatal(http.ListenAndServe(":"+port, nil)) + + // Use http.Server with explicit timeouts to satisfy gosec G114 recommendations + server := &http.Server{ + Addr: ":" + port, + ReadTimeout: requestTimeout + 5*time.Second, + WriteTimeout: requestTimeout + 5*time.Second, + IdleTimeout: 60 * time.Second, + } + // Start server and log errors consistently + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logError("Server error: %v", err) + os.Exit(1) + } } From ec50d4e50d90271d3fb05d525447ca601035fd8b Mon Sep 17 00:00:00 2001 From: KingPin <{ID}+{username}@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:58:18 -0400 Subject: [PATCH 10/18] fix(gosec): avoid log injection and handle encoder/close errors --- fanout.go | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/fanout.go b/fanout.go index 088421f..8fe9800 100644 --- a/fanout.go +++ b/fanout.go @@ -193,12 +193,12 @@ func init() { } else { size, err := humanize.ParseBytes(sizeStr) if err != nil { - log.Printf("Invalid MAX_BODY_SIZE '%s', using default: %v", sizeStr, err) + log.Printf("Invalid MAX_BODY_SIZE, using default: %v", sizeStr, err) maxBodySize = defaultMaxBodySize } else { // humanize.ParseBytes returns uint64; guard against overflow when converting to int64 if size > uint64(^uint64(0)>>1) { - log.Printf("MAX_BODY_SIZE '%s' too large, capping to default: %d", sizeStr, defaultMaxBodySize) + log.Printf("MAX_BODY_SIZE too large, capping to default: %d", sizeStr, defaultMaxBodySize) maxBodySize = defaultMaxBodySize } else { maxBodySize = int64(size) @@ -208,7 +208,7 @@ func init() { if timeout := os.Getenv("REQUEST_TIMEOUT"); timeout != "" { if d, err := time.ParseDuration(timeout); err != nil { - log.Printf("Invalid REQUEST_TIMEOUT '%s', using default: %v", timeout, err) + log.Printf("Invalid REQUEST_TIMEOUT, using default: %v", timeout, err) requestTimeout = defaultRequestTimeout } else { requestTimeout = d @@ -219,7 +219,7 @@ func init() { if timeout := os.Getenv("CLIENT_TIMEOUT"); timeout != "" { if d, err := time.ParseDuration(timeout); err != nil { - log.Printf("Invalid CLIENT_TIMEOUT '%s', using default: %v", timeout, err) + log.Printf("Invalid CLIENT_TIMEOUT, using default: %v", timeout, err) clientTimeout = defaultClientTimeout } else { clientTimeout = d @@ -263,7 +263,7 @@ func init() { if retriesStr := os.Getenv("MAX_RETRIES"); retriesStr != "" { if retries, err := strconv.Atoi(retriesStr); err != nil || retries < 0 { - log.Printf("Invalid MAX_RETRIES '%s', using default: %v", retriesStr, err) + log.Printf("Invalid MAX_RETRIES, using default: %v", retriesStr, err) maxRetries = defaultMaxRetries } else { maxRetries = retries @@ -323,7 +323,8 @@ func logWithLevel(level int, context map[string]string, format string, args ...i case logQueue <- entry: default: if level >= LogLevelError { - log.Printf("WARNING: Log queue full, logging ERROR directly: %s", entry.Message) + // Avoid logging untrusted message contents directly to prevent log injection (gosec G706) + log.Printf("WARNING: Log queue full, logging ERROR directly") } } } @@ -360,10 +361,16 @@ func echoHandler(w http.ResponseWriter, r *http.Request) { switch os.Getenv("ECHO_MODE_RESPONSE") { case "full": w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(echoData) + if err := json.NewEncoder(w).Encode(echoData); err != nil { + logError("Failed to encode echo response: %v", err) + return + } default: w.WriteHeader(http.StatusAccepted) - json.NewEncoder(w).Encode(map[string]string{"status": "echoed"}) + if err := json.NewEncoder(w).Encode(map[string]string{"status": "echoed"}); err != nil { + logError("Failed to encode echo short response: %v", err) + return + } } loggedBody := string(bodyBytes) @@ -381,7 +388,9 @@ func echoHandler(w http.ResponseWriter, r *http.Request) { func healthCheck(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]string{"status": "healthy"}) + if err := json.NewEncoder(w).Encode(map[string]string{"status": "healthy"}); err != nil { + logError("Failed to encode health response: %v", err) + } } type Response struct { @@ -418,7 +427,9 @@ func multiplex(w http.ResponseWriter, r *http.Request) { // into memory up to maxBodySize so retries can recreate the body safely. if getBody == nil { bodyBytes, readErr = io.ReadAll(io.LimitReader(r.Body, maxBodySize+1)) - r.Body.Close() + if err := r.Body.Close(); err != nil { + logWarn("Failed to close request body after read: %v", err) + } if readErr != nil { logError("Error reading request body: %v", readErr) writeJSONError(w, "Failed to read request body", http.StatusBadRequest) @@ -443,7 +454,9 @@ func multiplex(w http.ResponseWriter, r *http.Request) { return } bodyBytes, readErr = io.ReadAll(io.LimitReader(bodyReader, maxBodySize+1)) - bodyReader.Close() + if err := bodyReader.Close(); err != nil { + logWarn("Failed to close body reader after size check: %v", err) + } if readErr != nil { logError("Error reading body for size check: %v", readErr) From 4517c3132ee8b345468036728ce63ac5f18e7846 Mon Sep 17 00:00:00 2001 From: KingPin <{ID}+{username}@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:23:05 -0400 Subject: [PATCH 11/18] fix(gosec): avoid log injection and handle Close/Encode errors --- fanout.go | 42 ++++++++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/fanout.go b/fanout.go index 8fe9800..109515c 100644 --- a/fanout.go +++ b/fanout.go @@ -193,12 +193,12 @@ func init() { } else { size, err := humanize.ParseBytes(sizeStr) if err != nil { - log.Printf("Invalid MAX_BODY_SIZE, using default: %v", sizeStr, err) + log.Printf("Invalid MAX_BODY_SIZE, using default") maxBodySize = defaultMaxBodySize } else { // humanize.ParseBytes returns uint64; guard against overflow when converting to int64 if size > uint64(^uint64(0)>>1) { - log.Printf("MAX_BODY_SIZE too large, capping to default: %d", sizeStr, defaultMaxBodySize) + log.Printf("MAX_BODY_SIZE too large, capping to default: %d", defaultMaxBodySize) maxBodySize = defaultMaxBodySize } else { maxBodySize = int64(size) @@ -208,7 +208,7 @@ func init() { if timeout := os.Getenv("REQUEST_TIMEOUT"); timeout != "" { if d, err := time.ParseDuration(timeout); err != nil { - log.Printf("Invalid REQUEST_TIMEOUT, using default: %v", timeout, err) + log.Printf("Invalid REQUEST_TIMEOUT, using default") requestTimeout = defaultRequestTimeout } else { requestTimeout = d @@ -219,7 +219,7 @@ func init() { if timeout := os.Getenv("CLIENT_TIMEOUT"); timeout != "" { if d, err := time.ParseDuration(timeout); err != nil { - log.Printf("Invalid CLIENT_TIMEOUT, using default: %v", timeout, err) + log.Printf("Invalid CLIENT_TIMEOUT, using default") clientTimeout = defaultClientTimeout } else { clientTimeout = d @@ -263,7 +263,7 @@ func init() { if retriesStr := os.Getenv("MAX_RETRIES"); retriesStr != "" { if retries, err := strconv.Atoi(retriesStr); err != nil || retries < 0 { - log.Printf("Invalid MAX_RETRIES, using default: %v", retriesStr, err) + log.Printf("Invalid MAX_RETRIES, using default") maxRetries = defaultMaxRetries } else { maxRetries = retries @@ -576,7 +576,11 @@ func sendRequest(ctx context.Context, client *http.Client, target string, origin if err != nil { resp.Status = http.StatusInternalServerError resp.Error = fmt.Sprintf("Failed to create request: %v", err) - bodyReader.Close() + if bodyReader != nil { + if cerr := bodyReader.Close(); cerr != nil { + logWarn("Failed to close body reader after request creation: %v", cerr) + } + } logErrorWithContext(map[string]string{"target": target}, "%s", resp.Error) return resp } @@ -607,7 +611,9 @@ func sendRequest(ctx context.Context, client *http.Client, target string, origin ) // If a response object was returned alongside the error, close it and discard if response != nil { - response.Body.Close() + if cerr := response.Body.Close(); cerr != nil { + logWarn("Failed to close response body after network error: %v", cerr) + } response = nil } attempts++ @@ -630,7 +636,9 @@ func sendRequest(ctx context.Context, client *http.Client, target string, origin }, "Server error status, retrying request", ) - response.Body.Close() + if cerr := response.Body.Close(); cerr != nil { + logWarn("Failed to close response body after server error: %v", cerr) + } attempts++ continue } @@ -743,16 +751,22 @@ func writeJSON(w http.ResponseWriter, v interface{}) error { func writeJSONError(w http.ResponseWriter, message string, status int) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) - writeJSON(w, map[string]string{"error": message}) + if err := writeJSON(w, map[string]string{"error": message}); err != nil { + logError("Failed to write JSON error response: %v", err) + } + } func versionHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - writeJSON(w, map[string]string{ - "version": Version, - "git_commit": GitCommit, - "build_time": BuildTime, - }) + if err := writeJSON(w, map[string]string{ + "version": Version, + "git_commit": GitCommit, + "build_time": BuildTime, + }); err != nil { + logError("Failed to write version response: %v", err) + } + } func main() { From f0f36b0d1260a524e9d8d68552495b52d7808760 Mon Sep 17 00:00:00 2001 From: KingPin <{ID}+{username}@users.noreply.github.com> Date: Sun, 5 Apr 2026 14:38:20 -0400 Subject: [PATCH 12/18] fix: remove deprecated rand.Seed, min func shadow, net.Error.Temporary() - rand.Seed(time.Now().UnixNano()) is a no-op since Go 1.20 (auto-seeded) - min() shadowed the Go 1.21 built-in; removed so the built-in is used - net.Error.Temporary() deprecated since Go 1.18; removed from isRetryableError - Remove spurious w.WriteHeader(500) in writeJSON after encode failure; headers are already flushed at that point, the call was a no-op --- fanout.go | 31 ++++++------------------------- 1 file changed, 6 insertions(+), 25 deletions(-) diff --git a/fanout.go b/fanout.go index 109515c..030c78e 100644 --- a/fanout.go +++ b/fanout.go @@ -687,27 +687,16 @@ func isRetryableError(err error) bool { return false } - // Prefer typed checks for net errors and url errors + // Prefer typed checks for net errors and url errors. + // Note: net.Error.Temporary() is deprecated since Go 1.18 and intentionally omitted. var nerr net.Error - if errors.As(err, &nerr) { - if nerr.Timeout() || nerr.Temporary() { - return true - } + if errors.As(err, &nerr) && nerr.Timeout() { + return true } var ue *url.Error - if errors.As(err, &ue) { - // If the wrapped error is a net.Error with timeout/temporary, treat as retryable - var innerNetErr net.Error - if errors.As(ue.Err, &innerNetErr) { - if innerNetErr.Timeout() || innerNetErr.Temporary() { - return true - } - } - // Some url.Errors include "timeout" in the string; treat as retryable - if ue.Timeout() { - return true - } + if errors.As(err, &ue) && ue.Timeout() { + return true } // Fallback: substring matching for common transient messages @@ -730,18 +719,10 @@ func addJitter(d time.Duration) time.Duration { return time.Duration(jitter) } -func min(a, b time.Duration) time.Duration { - if a < b { - return a - } - return b -} - func writeJSON(w http.ResponseWriter, v interface{}) error { encoder := json.NewEncoder(w) encoder.SetEscapeHTML(false) if err := encoder.Encode(v); err != nil { - w.WriteHeader(http.StatusInternalServerError) logError("Error encoding JSON: %v", err) return err } From 36373ca36f2f8c238f0a31af5645f7079d410c34 Mon Sep 17 00:00:00 2001 From: KingPin <{ID}+{username}@users.noreply.github.com> Date: Sun, 5 Apr 2026 14:38:20 -0400 Subject: [PATCH 13/18] perf/fix: cache startup config; record bodySize for all body paths; reduce log noise - Parse TARGETS and ECHO_MODE_* once in init(), expose as package vars to avoid per-request os.Getenv calls and strings.Split on every request - Sort log context map keys for deterministic text output - Demote sensitive-header log from WARN to DEBUG (noisy for a proxy that legitimately forwards auth headers) - Record bodySize Prometheus metric for both pre-read body paths (previously only observed when ContentLength was known and >0) - Fix indentation of body Close() calls in both pre-read branches --- fanout.go | 45 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/fanout.go b/fanout.go index 030c78e..b458e24 100644 --- a/fanout.go +++ b/fanout.go @@ -16,6 +16,7 @@ import ( "net/http" "net/url" "os" + "sort" "strconv" "strings" "sync" @@ -70,6 +71,13 @@ var ( metricsEnabled bool httpClient *http.Client + // configuredTargets holds the parsed TARGETS list, populated at startup. + configuredTargets []string + + // echoModeHeader and echoModeResponse cache ECHO_MODE_* env vars. + echoModeHeader bool + echoModeResponse string + requestsTotal = promauto.NewCounterVec( prometheus.CounterOpts{ Name: "fanout_requests_total", @@ -167,6 +175,7 @@ func init() { for k, v := range entry.Context { parts = append(parts, fmt.Sprintf("%s=%s", k, v)) } + sort.Strings(parts) contextStr = " " + strings.Join(parts, " ") } log.Printf("[%s]%s %s", entry.Level, contextStr, entry.Message) @@ -272,7 +281,18 @@ func init() { maxRetries = defaultMaxRetries } - rand.Seed(time.Now().UnixNano()) + // Cache TARGETS at startup to avoid per-request env reads. + if t := os.Getenv("TARGETS"); t != "" && t != "localonly" { + for _, u := range strings.Split(t, ",") { + if trimmed := strings.TrimSpace(u); trimmed != "" { + configuredTargets = append(configuredTargets, trimmed) + } + } + } + + // Cache echo mode config. + echoModeHeader = strings.ToLower(os.Getenv("ECHO_MODE_HEADER")) == "true" + echoModeResponse = strings.ToLower(os.Getenv("ECHO_MODE_RESPONSE")) } func logDebug(format string, args ...interface{}) { @@ -333,7 +353,7 @@ func cloneHeaders(original http.Header) http.Header { cloned := make(http.Header) for k, vv := range original { if sensitiveHeaders[http.CanonicalHeaderKey(k)] { - logWarn("Sensitive header detected and propagated: %s", k) + logDebug("Sensitive header detected and forwarded: %s", k) } cloned[k] = vv } @@ -354,11 +374,11 @@ func echoHandler(w http.ResponseWriter, r *http.Request) { "body": string(bodyBytes), } - if os.Getenv("ECHO_MODE_HEADER") == "true" { + if echoModeHeader { w.Header().Set("X-Echo-Mode", "active") } - switch os.Getenv("ECHO_MODE_RESPONSE") { + switch echoModeResponse { case "full": w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(echoData); err != nil { @@ -427,9 +447,9 @@ func multiplex(w http.ResponseWriter, r *http.Request) { // into memory up to maxBodySize so retries can recreate the body safely. if getBody == nil { bodyBytes, readErr = io.ReadAll(io.LimitReader(r.Body, maxBodySize+1)) - if err := r.Body.Close(); err != nil { - logWarn("Failed to close request body after read: %v", err) - } + if err := r.Body.Close(); err != nil { + logWarn("Failed to close request body after read: %v", err) + } if readErr != nil { logError("Error reading request body: %v", readErr) writeJSONError(w, "Failed to read request body", http.StatusBadRequest) @@ -440,6 +460,9 @@ func multiplex(w http.ResponseWriter, r *http.Request) { writeJSONError(w, "Payload too large", http.StatusRequestEntityTooLarge) return } + if metricsEnabled { + bodySize.WithLabelValues(r.URL.Path).Observe(float64(len(bodyBytes))) + } // Provide a GetBody closure for retries getBody = func() (io.ReadCloser, error) { @@ -454,9 +477,9 @@ func multiplex(w http.ResponseWriter, r *http.Request) { return } bodyBytes, readErr = io.ReadAll(io.LimitReader(bodyReader, maxBodySize+1)) - if err := bodyReader.Close(); err != nil { - logWarn("Failed to close body reader after size check: %v", err) - } + if err := bodyReader.Close(); err != nil { + logWarn("Failed to close body reader after size check: %v", err) + } if readErr != nil { logError("Error reading body for size check: %v", readErr) @@ -648,7 +671,7 @@ func sendRequest(ctx context.Context, client *http.Client, target string, origin if response == nil { resp.Status = http.StatusServiceUnavailable - resp.Error = fmt.Sprintf("Request failed after %d attempts (no response received)", attempts+1) + resp.Error = fmt.Sprintf("Request failed after %d attempts (no response received)", attempts) logErrorWithContext(map[string]string{"target": target}, "%s", resp.Error) return resp } From 508ae883d63978e151f0c6b7d60dccb2777e66a8 Mon Sep 17 00:00:00 2001 From: KingPin <{ID}+{username}@users.noreply.github.com> Date: Sun, 5 Apr 2026 14:38:20 -0400 Subject: [PATCH 14/18] feat: X-Request-ID correlation; latency_seconds encoding; truncation detection - Generate a crypto/rand UUID-like X-Request-ID if not present on the incoming request; forward to all targets via cloneHeaders; echo back on the fan-out response for client-side correlation - Change Response.Latency from time.Duration (raw int64 ns) to float64 seconds with JSON key latency_seconds - self-documenting and consistent with Prometheus/OpenTelemetry conventions - Detect when io.LimitReader silently caps a response body at maxBodySize; set Truncated=true on the Response and emit a WARN log so callers know - Fix debug log in sendRequest that always logged latency=0s because resp.Latency is set by the caller after sendRequest returns; use time.Since(startTime) instead --- fanout.go | 64 ++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 44 insertions(+), 20 deletions(-) diff --git a/fanout.go b/fanout.go index b458e24..60ebe91 100644 --- a/fanout.go +++ b/fanout.go @@ -6,6 +6,7 @@ package main import ( "bytes" "context" + cryptorand "crypto/rand" "encoding/json" "errors" "fmt" @@ -413,13 +414,26 @@ func healthCheck(w http.ResponseWriter, r *http.Request) { } } +// Response holds the result of a single fan-out request to one target. +// Latency is expressed in seconds as a float64 for easy consumption. type Response struct { - Target string `json:"target"` - Status int `json:"status"` - Body string `json:"body,omitempty"` - Error string `json:"error,omitempty"` - Latency time.Duration `json:"latency"` - Attempts int `json:"attempts,omitempty"` + Target string `json:"target"` + Status int `json:"status"` + Body string `json:"body,omitempty"` + Error string `json:"error,omitempty"` + Latency float64 `json:"latency_seconds"` + Attempts int `json:"attempts,omitempty"` + Truncated bool `json:"truncated,omitempty"` +} + +// generateRequestID returns a UUID v4-like hex string using crypto/rand. +// Falls back to a timestamp string if the random source fails. +func generateRequestID() string { + b := make([]byte, 16) + if _, err := cryptorand.Read(b); err != nil { + return fmt.Sprintf("%d", time.Now().UnixNano()) + } + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]) } func multiplex(w http.ResponseWriter, r *http.Request) { @@ -438,6 +452,14 @@ func multiplex(w http.ResponseWriter, r *http.Request) { return } + // Set or generate X-Request-ID for correlation across all fan-out targets. + requestID := r.Header.Get("X-Request-ID") + if requestID == "" { + requestID = generateRequestID() + r.Header.Set("X-Request-ID", requestID) + } + w.Header().Set("X-Request-ID", requestID) + getBody := r.GetBody var bodyBytes []byte @@ -491,38 +513,34 @@ func multiplex(w http.ResponseWriter, r *http.Request) { writeJSONError(w, "Payload too large", http.StatusRequestEntityTooLarge) return } + if metricsEnabled { + bodySize.WithLabelValues(r.URL.Path).Observe(float64(len(bodyBytes))) + } } else { if metricsEnabled { bodySize.WithLabelValues(r.URL.Path).Observe(float64(r.ContentLength)) } } - targetsEnv := os.Getenv("TARGETS") - targets := strings.Split(targetsEnv, ",") - if len(targets) == 0 || (len(targets) == 1 && targets[0] == "") { + if len(configuredTargets) == 0 { logError("No targets configured (TARGETS env var is empty or not set)") writeJSONError(w, "No targets configured", http.StatusServiceUnavailable) return } - responses := make([]Response, 0, len(targets)) + responses := make([]Response, 0, len(configuredTargets)) var wg sync.WaitGroup - respChan := make(chan Response, len(targets)) + respChan := make(chan Response, len(configuredTargets)) - for _, target := range targets { - targetURL := strings.TrimSpace(target) - if targetURL == "" { - logWarn("Skipping empty target URL found in TARGETS list") - continue - } + for _, target := range configuredTargets { wg.Add(1) go func(target string) { defer wg.Done() start := time.Now() resp := sendRequest(ctx, httpClient, target, r, getBody, bodyBytes) - resp.Latency = time.Since(start) + resp.Latency = time.Since(start).Seconds() respChan <- resp - }(targetURL) + }(target) } go func() { @@ -692,12 +710,18 @@ func sendRequest(ctx context.Context, client *http.Client, target string, origin resp.Body = string(respBody) resp.Attempts = attempts + 1 + // Warn if the response body was silently capped at maxBodySize. + if int64(len(respBody)) == maxBodySize { + resp.Truncated = true + logWarnWithContext(map[string]string{"target": target}, "Response body truncated at limit (%d bytes)", maxBodySize) + } + logDebugWithContext( map[string]string{ "target": target, "status": strconv.Itoa(resp.Status), "attempts": strconv.Itoa(resp.Attempts), - "latency": resp.Latency.String(), + "latency": time.Since(startTime).String(), }, "Request completed successfully", ) From 376d87cb5dffd78ad5b855714d7f3dc8af916c4d Mon Sep 17 00:00:00 2001 From: KingPin <{ID}+{username}@users.noreply.github.com> Date: Sun, 5 Apr 2026 14:38:20 -0400 Subject: [PATCH 15/18] fix: implement -healthcheck flag; move flag handling before server setup - Dockerfile and compose.yml both invoke /fanout -healthcheck for health probes; the flag was never handled so the binary tried to bind :8080 (already in use) and always exited 1 -> container always unhealthy - Add -healthcheck: GET localhost:/health, exit 0 on 200 else exit 1 - Move -version and -healthcheck checks to the very top of main(), before any HTTP handler registration or log output - Simplify main() target validation to use cached configuredTargets slice instead of re-parsing TARGETS env var --- fanout.go | 50 ++++++++++++++++++++++++++++---------------------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/fanout.go b/fanout.go index 60ebe91..b4cdbd8 100644 --- a/fanout.go +++ b/fanout.go @@ -782,22 +782,40 @@ func writeJSONError(w http.ResponseWriter, message string, status int) { if err := writeJSON(w, map[string]string{"error": message}); err != nil { logError("Failed to write JSON error response: %v", err) } - } func versionHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if err := writeJSON(w, map[string]string{ - "version": Version, - "git_commit": GitCommit, - "build_time": BuildTime, - }); err != nil { - logError("Failed to write version response: %v", err) - } - + "version": Version, + "git_commit": GitCommit, + "build_time": BuildTime, + }); err != nil { + logError("Failed to write version response: %v", err) + } } func main() { + // Handle CLI flags before any server setup so they run cleanly without side effects. + if len(os.Args) > 1 { + switch os.Args[1] { + case "-version": + fmt.Printf("FanOut %s (commit: %s, built: %s)\n", Version, GitCommit, BuildTime) + os.Exit(0) + case "-healthcheck": + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + // #nosec G107 -- localhost-only health probe, port sourced from env + resp, err := http.Get("http://localhost:" + port + "/health") + if err != nil || resp.StatusCode != http.StatusOK { + os.Exit(1) + } + os.Exit(0) + } + } + log.SetOutput(os.Stdout) logInfo("Starting FanOut service version %s (commit: %s, built: %s)", Version, GitCommit, BuildTime) @@ -808,19 +826,12 @@ func main() { http.HandleFunc(endpointPath, echoHandler) logInfo("Running in ECHO MODE (Endpoint: %s)", endpointPath) } else { - targetList := strings.Split(targets, ",") - validTargets := 0 - for _, t := range targetList { - if strings.TrimSpace(t) != "" { - validTargets++ - } - } - if validTargets == 0 { + if len(configuredTargets) == 0 { logError("FATAL: No valid target URLs configured in TARGETS environment variable.") os.Exit(1) } http.HandleFunc(endpointPath, multiplex) - logInfo("Running in MULTIPLEX MODE (Endpoint: %s) with %d targets", endpointPath, validTargets) + logInfo("Running in MULTIPLEX MODE (Endpoint: %s) with %d targets", endpointPath, len(configuredTargets)) } http.HandleFunc("/health", healthCheck) @@ -839,11 +850,6 @@ func main() { port = "8080" } - if len(os.Args) > 1 && os.Args[1] == "-version" { - fmt.Printf("FanOut %s (commit: %s, built: %s)\n", Version, GitCommit, BuildTime) - os.Exit(0) - } - var displayMaxBody uint64 if maxBodySize < 0 { displayMaxBody = 0 From 1c4b62893b53de2674158542f25cef71da8a9645 Mon Sep 17 00:00:00 2001 From: KingPin <{ID}+{username}@users.noreply.github.com> Date: Sun, 5 Apr 2026 14:38:28 -0400 Subject: [PATCH 16/18] test: adapt tests for cached config vars; restore maxRetries global state - TestEchoHandlerSimpleMode/FullMode: env vars are now cached at init() time, not re-read per request; switch to setting echoModeHeader / echoModeResponse package vars directly with defer restore - TestMultiplexNoGetBody: TARGETS env is cached at startup; set configuredTargets directly with defer restore instead of os.Setenv - TestSendRequest / TestSendRequestNetworkError: add defer to restore the maxRetries global so tests cannot bleed state into each other - Drop now-unused os import from both test files --- fanout_additional_test.go | 9 ++++---- fanout_test.go | 43 ++++++++++++++++++++------------------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/fanout_additional_test.go b/fanout_additional_test.go index 8d7b65c..e7fcb9f 100644 --- a/fanout_additional_test.go +++ b/fanout_additional_test.go @@ -5,7 +5,6 @@ import ( "encoding/json" "net/http" "net/http/httptest" - "os" "testing" ) @@ -18,10 +17,10 @@ func TestMultiplexNoGetBody(t *testing.T) { })) defer server.Close() - // Set TARGETS to the mock server - origTargets := os.Getenv("TARGETS") - defer os.Setenv("TARGETS", origTargets) - os.Setenv("TARGETS", server.URL) + // Set configuredTargets directly (TARGETS env var is cached at init time, not per-request) + origTargets := configuredTargets + defer func() { configuredTargets = origTargets }() + configuredTargets = []string{server.URL} // Create a request WITHOUT GetBody (http.NewRequest leaves GetBody nil) body := []byte("hello") diff --git a/fanout_test.go b/fanout_test.go index 0bbc161..ba17fa4 100644 --- a/fanout_test.go +++ b/fanout_test.go @@ -9,7 +9,6 @@ import ( "io" "net/http" "net/http/httptest" - "os" "reflect" "strings" "testing" @@ -160,8 +159,8 @@ func TestWriteJSON(t *testing.T) { Body: "OK", Attempts: 0, // Due to omitempty tag, this won't appear in JSON when 0 }, - // Updated expected value - removed attempts since it has omitempty tag - expected: `{"target":"http://example.com","status":200,"body":"OK","latency":0}`, + // Updated expected value - removed attempts since it has omitempty tag; latency_seconds is float64 + expected: `{"target":"http://example.com","status":200,"body":"OK","latency_seconds":0}`, }, } @@ -266,17 +265,16 @@ func TestHealthCheck(t *testing.T) { // TestEchoHandlerSimpleMode tests the echo handler in simple mode func TestEchoHandlerSimpleMode(t *testing.T) { - // Save and restore original env vars - originalHeader := os.Getenv("ECHO_MODE_HEADER") - originalResponse := os.Getenv("ECHO_MODE_RESPONSE") + // Save and restore cached echo mode vars (env vars are read at init time, not per-request) + origHeader := echoModeHeader + origResponse := echoModeResponse defer func() { - os.Setenv("ECHO_MODE_HEADER", originalHeader) - os.Setenv("ECHO_MODE_RESPONSE", originalResponse) + echoModeHeader = origHeader + echoModeResponse = origResponse }() - // Set environment for this test - os.Setenv("ECHO_MODE_HEADER", "false") - os.Setenv("ECHO_MODE_RESPONSE", "simple") + echoModeHeader = false + echoModeResponse = "simple" // Create a request with a test body body := []byte(`{"test":"data"}`) @@ -308,17 +306,16 @@ func TestEchoHandlerSimpleMode(t *testing.T) { // TestEchoHandlerFullMode tests the echo handler in full mode func TestEchoHandlerFullMode(t *testing.T) { - // Save and restore original env vars - originalHeader := os.Getenv("ECHO_MODE_HEADER") - originalResponse := os.Getenv("ECHO_MODE_RESPONSE") + // Save and restore cached echo mode vars (env vars are read at init time, not per-request) + origHeader := echoModeHeader + origResponse := echoModeResponse defer func() { - os.Setenv("ECHO_MODE_HEADER", originalHeader) - os.Setenv("ECHO_MODE_RESPONSE", originalResponse) + echoModeHeader = origHeader + echoModeResponse = origResponse }() - // Set environment for this test - os.Setenv("ECHO_MODE_HEADER", "true") - os.Setenv("ECHO_MODE_RESPONSE", "full") + echoModeHeader = true + echoModeResponse = "full" // Create a request with a test body body := []byte(`{"test":"data"}`) @@ -383,7 +380,9 @@ func TestSendRequest(t *testing.T) { t.Fatalf("Failed to create request: %v", err) } - // Set app-level retry count for test + // Set app-level retry count for test (restored on exit) + origMaxRetries := maxRetries + defer func() { maxRetries = origMaxRetries }() maxRetries = 2 // Call sendRequest with mock server URL @@ -416,7 +415,9 @@ func TestSendRequestNetworkError(t *testing.T) { t.Fatalf("Failed to create request: %v", err) } - // Set app-level retry count for test + // Set app-level retry count for test (restored on exit) + origMaxRetries := maxRetries + defer func() { maxRetries = origMaxRetries }() maxRetries = 1 // Call sendRequest with a non-existent endpoint (will cause error) From 3c1c06fe857c67bd186be6f95d24d4c1c35699a2 Mon Sep 17 00:00:00 2001 From: KingPin <{ID}+{username}@users.noreply.github.com> Date: Sun, 5 Apr 2026 14:38:34 -0400 Subject: [PATCH 17/18] ci: enforce lint/vet gate; add gosec scan to binary-release workflow - docker-image.yml: remove '|| true' from gofmt/go vet step so formatting and vet failures actually break the build; previously both tools were silently swallowed and the step could never fail - binary-release.yml: add a gosec security scan step (matching the step that already exists in docker-image.yml) so published binaries receive the same security scrutiny as Docker images --- .github/workflows/binary-release.yml | 6 +++++- .github/workflows/docker-image.yml | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/binary-release.yml b/.github/workflows/binary-release.yml index a30d49e..6a6c1aa 100644 --- a/.github/workflows/binary-release.yml +++ b/.github/workflows/binary-release.yml @@ -37,7 +37,11 @@ jobs: run: | go mod download go test -v ./... - + + - name: Security scan (gosec) + run: | + docker run --rm -v ${{ github.workspace }}:/src -w /src securego/gosec:latest gosec ./... + - name: Build for multiple platforms run: | mkdir -p dist diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 0237d66..16fad6a 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -55,7 +55,8 @@ jobs: - name: Static analysis (gofmt, go vet) run: | - docker run --rm -v ${{ github.workspace }}:/src -w /src golang:1.24 sh -c "gofmt -l . || true; go vet ./... || true" + docker run --rm -v ${{ github.workspace }}:/src -w /src golang:1.24 sh -c \ + 'GOFMT_OUT=$(gofmt -l .); [ -z "$GOFMT_OUT" ] || (echo "$GOFMT_OUT"; exit 1); go vet ./...' - name: Security scan (gosec) run: | From 3cc5924df8469fcb7fe4d02d2aa52d9ac47463f5 Mon Sep 17 00:00:00 2001 From: KingPin <{ID}+{username}@users.noreply.github.com> Date: Sun, 5 Apr 2026 15:34:23 -0400 Subject: [PATCH 18/18] fix: address Copilot review comments - Truncation detection: read maxBodySize+1 bytes so an exact-size response is not falsely flagged; only set Truncated=true and trim when len(respBody) > maxBodySize - Target URL validation: validate each TARGETS entry as an absolute http/https URL with non-empty host at startup in init(); skip and warn on invalid entries rather than passing unvalidated strings to http.NewRequestWithContext; update #nosec annotation accordingly - Healthcheck client: use http.Client{Timeout:5s} in -healthcheck to avoid hanging indefinitely; always close resp.Body before exiting - TestMultiplexNoGetBody: http.NewRequest sets GetBody for *bytes.Reader; wrap in io.NopCloser to ensure req.GetBody==nil and the test actually exercises the pre-read code path - Pin securego/gosec to v2.22.4 in both CI workflows for reproducible builds instead of pulling :latest --- .github/workflows/binary-release.yml | 2 +- .github/workflows/docker-image.yml | 2 +- fanout.go | 45 ++++++++++++++++++---------- fanout_additional_test.go | 10 ++++--- 4 files changed, 38 insertions(+), 21 deletions(-) diff --git a/.github/workflows/binary-release.yml b/.github/workflows/binary-release.yml index 6a6c1aa..3b4a1e5 100644 --- a/.github/workflows/binary-release.yml +++ b/.github/workflows/binary-release.yml @@ -40,7 +40,7 @@ jobs: - name: Security scan (gosec) run: | - docker run --rm -v ${{ github.workspace }}:/src -w /src securego/gosec:latest gosec ./... + docker run --rm -v ${{ github.workspace }}:/src -w /src securego/gosec:v2.22.4 gosec ./... - name: Build for multiple platforms run: | diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 16fad6a..7c8d79b 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -60,7 +60,7 @@ jobs: - name: Security scan (gosec) run: | - docker run --rm -v ${{ github.workspace }}:/src -w /src securego/gosec:latest gosec ./... + docker run --rm -v ${{ github.workspace }}:/src -w /src securego/gosec:v2.22.4 gosec ./... - name: Run unit tests (inside Docker) run: | diff --git a/fanout.go b/fanout.go index b4cdbd8..709b91b 100644 --- a/fanout.go +++ b/fanout.go @@ -282,12 +282,20 @@ func init() { maxRetries = defaultMaxRetries } - // Cache TARGETS at startup to avoid per-request env reads. + // Cache TARGETS at startup, validating each entry is an absolute http/https URL. if t := os.Getenv("TARGETS"); t != "" && t != "localonly" { for _, u := range strings.Split(t, ",") { - if trimmed := strings.TrimSpace(u); trimmed != "" { - configuredTargets = append(configuredTargets, trimmed) + trimmed := strings.TrimSpace(u) + if trimmed == "" { + continue + } + parsed, err := url.Parse(trimmed) + if err != nil || !parsed.IsAbs() || parsed.Host == "" || + (parsed.Scheme != "http" && parsed.Scheme != "https") { + log.Printf("WARNING: Skipping invalid target URL %q (must be absolute http/https with non-empty host)", trimmed) + continue } + configuredTargets = append(configuredTargets, trimmed) } } @@ -611,8 +619,8 @@ func sendRequest(ctx context.Context, client *http.Client, target string, origin } } - // target was validated by caller (must be absolute http/https with host). Suppress gosec SSRF warning. - // #nosec G704 -- validated target URL in multiplex + // target is validated as an absolute http/https URL in init() when configuredTargets is built. + // #nosec G107 -- validated target URL at startup req, err := http.NewRequestWithContext(ctx, originalReq.Method, target, bodyReader) if err != nil { resp.Status = http.StatusInternalServerError @@ -637,7 +645,7 @@ func sendRequest(ctx context.Context, client *http.Client, target string, origin req.Header.Set("X-Retry-Count", strconv.Itoa(attempts)) } - response, err = client.Do(req) // #nosec G704 -- target validated by caller + response, err = client.Do(req) // #nosec G107 -- target validated at startup if err != nil { if isRetryableError(err) && attempts < maxRetries { @@ -695,7 +703,9 @@ func sendRequest(ctx context.Context, client *http.Client, target string, origin } defer response.Body.Close() - respBody, readErr := io.ReadAll(io.LimitReader(response.Body, maxBodySize)) + // Read one extra byte so we can distinguish an exact-maxBodySize response + // from a genuinely truncated one (io.LimitReader alone cannot tell the difference). + respBody, readErr := io.ReadAll(io.LimitReader(response.Body, maxBodySize+1)) if readErr != nil { resp.Status = http.StatusInternalServerError resp.Error = fmt.Sprintf("Failed to read response body: %v", readErr) @@ -706,16 +716,16 @@ func sendRequest(ctx context.Context, client *http.Client, target string, origin return resp } - resp.Status = response.StatusCode - resp.Body = string(respBody) - resp.Attempts = attempts + 1 - - // Warn if the response body was silently capped at maxBodySize. - if int64(len(respBody)) == maxBodySize { + if int64(len(respBody)) > maxBodySize { resp.Truncated = true + respBody = respBody[:maxBodySize] logWarnWithContext(map[string]string{"target": target}, "Response body truncated at limit (%d bytes)", maxBodySize) } + resp.Status = response.StatusCode + resp.Body = string(respBody) + resp.Attempts = attempts + 1 + logDebugWithContext( map[string]string{ "target": target, @@ -807,9 +817,14 @@ func main() { if port == "" { port = "8080" } + hcClient := &http.Client{Timeout: 5 * time.Second} // #nosec G107 -- localhost-only health probe, port sourced from env - resp, err := http.Get("http://localhost:" + port + "/health") - if err != nil || resp.StatusCode != http.StatusOK { + hcResp, err := hcClient.Get("http://localhost:" + port + "/health") + if err != nil { + os.Exit(1) + } + hcResp.Body.Close() + if hcResp.StatusCode != http.StatusOK { os.Exit(1) } os.Exit(0) diff --git a/fanout_additional_test.go b/fanout_additional_test.go index e7fcb9f..b7222a5 100644 --- a/fanout_additional_test.go +++ b/fanout_additional_test.go @@ -1,10 +1,11 @@ package main import ( - "bytes" "encoding/json" + "io" "net/http" "net/http/httptest" + "strings" "testing" ) @@ -22,9 +23,10 @@ func TestMultiplexNoGetBody(t *testing.T) { defer func() { configuredTargets = origTargets }() configuredTargets = []string{server.URL} - // Create a request WITHOUT GetBody (http.NewRequest leaves GetBody nil) - body := []byte("hello") - req, err := http.NewRequest("POST", "/fanout", bytes.NewReader(body)) + // http.NewRequest sets GetBody automatically for *bytes.Reader / *bytes.Buffer. + // Wrap in io.NopCloser so the body is a plain io.ReadCloser and GetBody stays nil, + // which forces multiplex into the pre-read code path. + req, err := http.NewRequest("POST", "/fanout", io.NopCloser(strings.NewReader("hello"))) if err != nil { t.Fatalf("Failed to create request: %v", err) }