package backend
import (
"bytes"
"context"
"fmt"
"io"
"net"
"net/http"
"net/http/httputil"
"net/netip"
"net/url"
"sync"
"go.uber.org/zap"
)
type TransparentBackend struct {
logger *zap.Logger
url *url.URL
trusted []netip.Prefix
ctx context.Context
ctxLock sync.Mutex
}
func NewTransparentBackend(logger *zap.Logger, u *url.URL, trusted []string) (Backend, error) {
trn := make([]netip.Prefix, 0, len(trusted))
for _, c := range trusted {
p, err := netip.ParsePrefix(c)
if err != nil {
return nil, err
}
trn = append(trn, p)
}
return &TransparentBackend{
logger: logger,
url: u,
trusted: trn,
}, nil
}
const maxBackendRedirects = 10
// redirectFollowingTransport wraps an http.RoundTripper to transparently
// follow 307/308 redirects from backend servers. This is needed because
// httputil.ReverseProxy uses Transport.RoundTrip() directly, which does
// not follow redirects. Many MCP backends (Starlette/FastAPI) redirect
// /mcp → /mcp/ via 307, which POST-based MCP clients won't follow.
type redirectFollowingTransport struct {
base http.RoundTripper
targetHost string // only follow redirects to this host
}
func (t *redirectFollowingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Buffer body upfront so we can replay it on redirect.
// MCP JSON-RPC payloads are small, so this is fine.
var bodyBytes []byte
if req.Body != nil {
var err error
bodyBytes, err = io.ReadAll(req.Body)
if err != nil {
return nil, fmt.Errorf("failed to read request body: %w", err)
}
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
}
for i := 0; i <= maxBackendRedirects; i++ {
resp, err := t.base.RoundTrip(req)
if err != nil {
return nil, err
}
// Only follow 307 (Temporary) and 308 (Permanent) redirects.
// These preserve the original method and body per HTTP spec.
if resp.StatusCode != http.StatusTemporaryRedirect &&
resp.StatusCode != http.StatusPermanentRedirect {
return resp, nil
}
location := resp.Header.Get("Location")
if location == "" {
return resp, nil
}
// Resolve relative Location against the request URL
newURL, err := req.URL.Parse(location)
if err != nil {
return resp, nil
}
// Security: only follow redirects to the same backend host.
// Don't leak Authorization headers or body to arbitrary hosts.
if newURL.Host != "" && newURL.Host != t.targetHost {
return resp, nil
}
// Drain and close the redirect response body
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
// Clone the request for the next hop, replaying the body
newReq := req.Clone(req.Context())
newReq.URL = newURL
newReq.Host = newURL.Host
if bodyBytes != nil {
newReq.Body = io.NopCloser(bytes.NewReader(bodyBytes))
newReq.ContentLength = int64(len(bodyBytes))
}
req = newReq
}
return nil, fmt.Errorf("backend exceeded maximum redirects (%d)", maxBackendRedirects)
}
func (p *TransparentBackend) Run(ctx context.Context) (http.Handler, error) {
p.ctxLock.Lock()
defer p.ctxLock.Unlock()
if p.ctx != nil {
return nil, fmt.Errorf("transparent backend is already running")
}
p.ctx = ctx
rp := httputil.ReverseProxy{
Transport: &redirectFollowingTransport{
base: http.DefaultTransport,
targetHost: p.url.Host,
},
FlushInterval: -1,
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetURL(p.url)
if p.isTrusted(pr.In.RemoteAddr) {
pr.Out.Header["X-Forwarded-For"] = pr.In.Header["X-Forwarded-For"]
}
pr.SetXForwarded()
if p.isTrusted(pr.In.RemoteAddr) {
if v := pr.In.Header.Get("X-Forwarded-Host"); v != "" {
pr.Out.Header.Set("X-Forwarded-Host", v)
}
if v := pr.In.Header.Get("X-Forwarded-Proto"); v != "" {
pr.Out.Header.Set("X-Forwarded-Proto", v)
}
if v := pr.In.Header.Get("X-Forwarded-Port"); v != "" {
pr.Out.Header.Set("X-Forwarded-Port", v)
}
}
},
}
return &rp, nil
}
func (p *TransparentBackend) isTrusted(hostport string) bool {
if host, _, err := net.SplitHostPort(hostport); err == nil {
hostport = host
}
ip, err := netip.ParseAddr(hostport)
if err != nil {
return false
}
if ip.Is4In6() {
ip = ip.Unmap()
}
for _, p := range p.trusted {
if p.Contains(ip) {
return true
}
}
return false
}
func (p *TransparentBackend) Wait() error {
if p.ctx == nil {
return nil
}
<-p.ctx.Done()
return nil
}
func (p *TransparentBackend) Close() error {
return nil
}