Skip to content

feat: add static HTTP data-plane proxy - #5

Closed
SShogun wants to merge 2 commits into
mainfrom
codex/m1-static-http-proxy
Closed

SShogun wants to merge 2 commits into
mainfrom
codex/m1-static-http-proxy

Conversation

@SShogun

@SShogun SShogun commented Oct 10, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Add the M1 static HTTP reverse proxy with streaming request/response forwarding, cancellation propagation, hop-by-hop header filtering, and graceful shutdown.
  • Add bounded listener settings and structured request outcomes, including aborted streams.
  • Classify upstream transport failures as gateway failures while preserving valid upstream HTTP error responses as successful proxy exchanges.
  • Record the Go 1.26.9 toolchain and correct the historical Buf compatibility decision.

Verification

  • Focused transport-error outcome regression: RED before the fix, GREEN after.
  • Valid upstream HTTP 502 regression confirms it is not mislabeled as a gateway transport failure.
  • Pinned Go 1.26.9 make verify passed locally.
  • Exact-head GitHub Actions run 38063221638 passed contracts, static, unit, race, and security. Sourcery review passed.
  • git diff --check passed.

Scope

M1 proves the static HTTP request lifecycle. Dynamic route/cluster configuration and control-plane publication remain later milestones.

Copilot AI balanced review requested due to automatic review settings October 10, 2026 14:19
@sourcery-ai

sourcery-ai Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

The data-plane binary now runs a static, environment-configurable HTTP reverse proxy with streaming, cancellation, hop-by-hop header filtering, structured outcomes, server timeouts, and bounded graceful shutdown; comprehensive M1 tests and documentation capture the implemented behavior and verification evidence.

Sequence diagram for streaming proxy requests and cancellation

sequenceDiagram
    participant Client as HTTP Client
    participant Server as HTTP Server
    participant Proxy as ReverseProxy
    participant Upstream as Static Upstream

    Client->>Server: HTTP request
    Server->>Proxy: ServeHTTP
    Proxy->>Upstream: Forward request
    Upstream-->>Proxy: Response headers
    Proxy-->>Client: Response headers
    loop response chunks
        Upstream-->>Proxy: Response body chunk
        Proxy-->>Client: Stream chunk
    end
    Client--xServer: Cancel request
    Server--xProxy: Request context cancellation
    Proxy--xUpstream: Cancel upstream request
Loading

Sequence diagram for graceful data-plane shutdown

sequenceDiagram
    participant Signal as SIGINT or SIGTERM
    participant Lifecycle as Lifecycle
    participant Server as HTTP Server
    participant Proxy as Reverse Proxy
    participant Upstream as Static Upstream

    Signal->>Lifecycle: NotifyContext cancellation
    Lifecycle->>Server: Shutdown timeout
    Server-->>Server: Stop accepting requests
    Server->>Proxy: Drain in-flight request
    Proxy->>Upstream: Complete active request
    Upstream-->>Proxy: Final response
    Proxy-->>Server: Request finished
    Server->>Proxy: CloseIdleConnections
    Server-->>Lifecycle: Shutdown result
Loading

File-Level Changes

Change Details Files
Replace the data-plane lifecycle scaffold with a configurable static reverse-proxy process.
  • Read listen and upstream URLs from environment variables with localhost defaults.
  • Validate a single absolute HTTP/HTTPS upstream and rewrite all requests through it.
  • Preserve request/response streaming, forwarding semantics, and cancellation while using a cloned transport.
cmd/gatehouse-data/main.go
internal/dataplane/proxy.go
Add HTTP protocol handling and structured request outcome reporting.
  • Strip hop-by-hop headers from requests and final/informational responses.
  • Flush streamed response data without buffering the upstream body.
  • Track active requests and log successful and aborted upstream outcomes, including panic recovery.
internal/dataplane/proxy.go
internal/dataplane/proxy_test.go
Introduce bounded HTTP server lifecycle management and graceful draining.
  • Configure read-header and idle timeouts plus a maximum header size while omitting write timeout for streaming.
  • Bind listeners asynchronously, expose the actual address, wait for serve termination, and shut down with context-bounded draining.
  • Close idle proxy connections during shutdown.
internal/dataplane/server.go
internal/dataplane/server_test.go
cmd/gatehouse-data/main.go
Add M1 integration coverage for proxy correctness, resource behavior, and shutdown.
  • Test forwarding, informational responses, cancellation propagation, aborted-stream logging, streaming-first-byte behavior, and hop-by-hop filtering.
  • Measure bounded heap growth for large streamed responses and verify concurrent cancellation cleanup.
  • Verify in-flight requests drain successfully during server shutdown.
internal/dataplane/proxy_test.go
internal/dataplane/server_test.go
Document the completed M1 static proxy behavior and evidence.
  • Update runtime status, defaults, configuration overrides, proxy semantics, and shutdown behavior in the README.
  • Mark M1 gates complete with memory, logging, cancellation, drain, and leak-test evidence.
README.md
docs/Gatehouse_PLAN.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 6 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="cmd/gatehouse-data/main.go" line_range="38" />
<code_context>
+	}
+	server, err := dataplane.NewServer(proxy, dataplane.ServerConfig{
+		Address: envOrDefault("GATEHOUSE_LISTEN_ADDR", "127.0.0.1:8080"),
+	})
+	if err != nil {
+		return fmt.Errorf("configure data-plane listener: %w", err)
</code_context>
<issue_to_address>
**Protected upstream exposed without authentication**

When the operator sets `GATEHOUSE_LISTEN_ADDR` to a non-loopback interface and the upstream has protected endpoints, `NewServer` listens on the configured non-loopback address, and `Proxy.ServeHTTP` forwards every method and path without authenticating callers, so external users can reach protected upstream endpoints.

Keep unauthenticated mode loopback-only, or require authentication before accepting requests on an externally reachable listener.

Also at `internal/dataplane/proxy.go:102`.
</issue_to_address>

### Comment 2
<location path="internal/dataplane/server.go" line_range="73-76" />
<code_context>
+	s.listener = listener
+	s.server = &http.Server{
+		Handler:           s.handler,
+		ReadHeaderTimeout: s.cfg.ReadHeaderTimeout,
+		IdleTimeout:       s.cfg.IdleTimeout,
+		MaxHeaderBytes:    s.cfg.MaxHeaderBytes,
+	}
+	s.serveErr = make(chan error, 1)
+	s.started = true
</code_context>
<issue_to_address>
**Slow requests exhaust proxy resources**

When an attacker can reach the listener and sends a body slowly or causes upstream requests to stall, `http.Server` bounds header reading but sets no request-body read timeout, while the cloned upstream transport has no response-header timeout; slow clients or stalled upstreams therefore keep requests and connections active indefinitely, exhausting resources under concurrency.

Add bounded request/body and upstream-response waiting policies plus concurrency protection that preserve intended streaming behavior.

Also at `internal/dataplane/proxy.go:47-55`.
</issue_to_address>

### Comment 3
<location path="internal/dataplane/proxy.go" line_range="106-110" />
<code_context>
+}
+
+type statusRecorder struct {
+	http.ResponseWriter
+	status int
+}
+
+func (w *statusRecorder) Unwrap() http.ResponseWriter { return w.ResponseWriter }
+
+func (w *statusRecorder) WriteHeader(status int) {
</code_context>
<issue_to_address>
**Protocol upgrades fail**

When a client requests an HTTP protocol upgrade and the upstream returns 101 Switching Protocols, `statusRecorder` does not implement or forward `http.Hijacker`, so `httputil.ReverseProxy` cannot take over the client connection and establish the tunnel; the client sees a failed upgrade.

Forward `http.Hijacker` through `statusRecorder` so `ReverseProxy` can establish upgraded connections.

Also at `internal/dataplane/proxy.go:75`.
</issue_to_address>

### Comment 4
<location path="internal/dataplane/proxy.go" line_range="98" />
<code_context>
+		p.logger.Info("proxy request complete",
+			"method", r.Method,
+			"status", status,
+			"outcome", "success",
+			"duration", time.Since(started),
+		)
</code_context>
<issue_to_address>
**Failed requests logged as successful**

When the upstream request fails or its response body aborts, the `ErrorHandler` writes a 502 and returns normally, and `httputil.ReverseProxy` also returns normally when response copying fails. The deferred completion log still sets `outcome` to `success` (only the panic branch records failure), so operators see failed proxy requests counted as successful.

Track proxy errors, including transport failures and response-copy failures, and log `outcome=failure` instead of unconditionally logging success.

Also at `internal/dataplane/proxy.go:56-59`, `internal/dataplane/proxy.go:95`, `internal/dataplane/proxy.go:99`.
</issue_to_address>

### Comment 5
<location path="internal/dataplane/proxy_test.go" line_range="487" />
<code_context>
+	if err != nil {
+		t.Fatal(err)
+	}
+	req.Header.Set("Connection", "X-Hop-By-Hop")
+	req.Header.Set("X-Hop-By-Hop", "must-not-forward")
+	resp, err := http.DefaultClient.Do(req)
</code_context>
<issue_to_address>
**Connection header leaks go undetected**

When a proxy regression forwards `Connection` while still stripping its nominated header, `TestProxyStripsHopByHopRequestHeaders` records only `X-Hop-By-Hop` from the request (lines 469, 487–497) and checks only `X-Upstream-Hop` in the response (lines 470–471, 497–499), so a proxy that forwards `Connection` but drops its nominated header passes both checks.

Also assert that `Connection` is absent from the upstream request and the client response.

Also at `internal/dataplane/proxy_test.go:470`.
</issue_to_address>

### Comment 6
<location path="internal/dataplane/server_test.go" line_range="73-74" />
<code_context>
+	select {
+	case err := <-shutdownResult:
+		t.Fatalf("server shutdown returned before in-flight request drained: %v", err)
+	case <-time.After(25 * time.Millisecond):
+	}
+	releaseOnce.Do(func() { close(releaseUpstream) })
+
</code_context>
<issue_to_address>
**Shutdown test can miss early return**

When the shutdown goroutine is delayed past the 25 ms timeout, the test releases the upstream request after the timer fires, so an implementation that returns early without draining can pass if shutdown was not scheduled before the release.

Synchronize on shutdown actually starting or use another deterministic signal before releasing the upstream request.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 6 findings to address first, and if the proxy routes requests to the wrong upstream or mishandles HTTP boundaries, it could expose request data or cause an outage for traffic passing through the new listener. Reverting stops future proxying, but it cannot undo requests already served or data already exposed.

Blocking findings: cmd/gatehouse-data/main.go:38, internal/dataplane/server.go:76, internal/dataplane/proxy.go:110, internal/dataplane/proxy.go:98, internal/dataplane/proxy_test.go:487, and 1 more


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

}
server, err := dataplane.NewServer(proxy, dataplane.ServerConfig{
Address: envOrDefault("GATEHOUSE_LISTEN_ADDR", "127.0.0.1:8080"),
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High · Protected upstream exposed without authentication

When the operator sets GATEHOUSE_LISTEN_ADDR to a non-loopback interface and the upstream has protected endpoints, NewServer listens on the configured non-loopback address, and Proxy.ServeHTTP forwards every method and path without authenticating callers, so external users can reach protected upstream endpoints.

Keep unauthenticated mode loopback-only, or require authentication before accepting requests on an externally reachable listener.

Also at internal/dataplane/proxy.go:102.

Prompt for AI agents
In `cmd/gatehouse-data/main.go` at line 38:

**Protected upstream exposed without authentication**

When the operator sets `GATEHOUSE_LISTEN_ADDR` to a non-loopback interface and the upstream has protected endpoints, `NewServer` listens on the configured non-loopback address, and `Proxy.ServeHTTP` forwards every method and path without authenticating callers, so external users can reach protected upstream endpoints.

Keep unauthenticated mode loopback-only, or require authentication before accepting requests on an externally reachable listener.

Also at `internal/dataplane/proxy.go:102`.

Comment on lines +73 to +76
ReadHeaderTimeout: s.cfg.ReadHeaderTimeout,
IdleTimeout: s.cfg.IdleTimeout,
MaxHeaderBytes: s.cfg.MaxHeaderBytes,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Slow requests exhaust proxy resources

When an attacker can reach the listener and sends a body slowly or causes upstream requests to stall, http.Server bounds header reading but sets no request-body read timeout, while the cloned upstream transport has no response-header timeout; slow clients or stalled upstreams therefore keep requests and connections active indefinitely, exhausting resources under concurrency.

Add bounded request/body and upstream-response waiting policies plus concurrency protection that preserve intended streaming behavior.

Also at internal/dataplane/proxy.go:47-55.

Prompt for AI agents
In `internal/dataplane/server.go` at lines 73-76:

**Slow requests exhaust proxy resources**

When an attacker can reach the listener and sends a body slowly or causes upstream requests to stall, `http.Server` bounds header reading but sets no request-body read timeout, while the cloned upstream transport has no response-header timeout; slow clients or stalled upstreams therefore keep requests and connections active indefinitely, exhausting resources under concurrency.

Add bounded request/body and upstream-response waiting policies plus concurrency protection that preserve intended streaming behavior.

Also at `internal/dataplane/proxy.go:47-55`.

Comment on lines +106 to +110
http.ResponseWriter
status int
}

func (w *statusRecorder) Unwrap() http.ResponseWriter { return w.ResponseWriter }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Protocol upgrades fail

When a client requests an HTTP protocol upgrade and the upstream returns 101 Switching Protocols, statusRecorder does not implement or forward http.Hijacker, so httputil.ReverseProxy cannot take over the client connection and establish the tunnel; the client sees a failed upgrade.

Forward http.Hijacker through statusRecorder so ReverseProxy can establish upgraded connections.

Also at internal/dataplane/proxy.go:75.

Prompt for AI agents
In `internal/dataplane/proxy.go` at lines 106-110:

**Protocol upgrades fail**

When a client requests an HTTP protocol upgrade and the upstream returns 101 Switching Protocols, `statusRecorder` does not implement or forward `http.Hijacker`, so `httputil.ReverseProxy` cannot take over the client connection and establish the tunnel; the client sees a failed upgrade.

Forward `http.Hijacker` through `statusRecorder` so `ReverseProxy` can establish upgraded connections.

Also at `internal/dataplane/proxy.go:75`.

Comment thread internal/dataplane/proxy.go Resolved
if err != nil {
t.Fatal(err)
}
req.Header.Set("Connection", "X-Hop-By-Hop")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Connection header leaks go undetected

When a proxy regression forwards Connection while still stripping its nominated header, TestProxyStripsHopByHopRequestHeaders records only X-Hop-By-Hop from the request (lines 469, 487–497) and checks only X-Upstream-Hop in the response (lines 470–471, 497–499), so a proxy that forwards Connection but drops its nominated header passes both checks.

Also assert that Connection is absent from the upstream request and the client response.

Also at internal/dataplane/proxy_test.go:470.

Prompt for AI agents
In `internal/dataplane/proxy_test.go` at line 487:

**Connection header leaks go undetected**

When a proxy regression forwards `Connection` while still stripping its nominated header, `TestProxyStripsHopByHopRequestHeaders` records only `X-Hop-By-Hop` from the request (lines 469, 487–497) and checks only `X-Upstream-Hop` in the response (lines 470–471, 497–499), so a proxy that forwards `Connection` but drops its nominated header passes both checks.

Also assert that `Connection` is absent from the upstream request and the client response.

Also at `internal/dataplane/proxy_test.go:470`.

Comment on lines +73 to +74
case <-time.After(25 * time.Millisecond):
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Shutdown test can miss early return

When the shutdown goroutine is delayed past the 25 ms timeout, the test releases the upstream request after the timer fires, so an implementation that returns early without draining can pass if shutdown was not scheduled before the release.

Synchronize on shutdown actually starting or use another deterministic signal before releasing the upstream request.

Prompt for AI agents
In `internal/dataplane/server_test.go` at lines 73-74:

**Shutdown test can miss early return**

When the shutdown goroutine is delayed past the 25 ms timeout, the test releases the upstream request after the timer fires, so an implementation that returns early without draining can pass if shutdown was not scheduled before the release.

Synchronize on shutdown actually starting or use another deterministic signal before releasing the upstream request.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants