feat(14-01): guarded fetchguard client replayed through a tide upstream fake

- fetchguard.NewClient with Do, Send, Get and PostJSON over a capped, never-redirecting transport
- WithTransport: code-only context seam for offline replay; Result gains Header
- tide UpstreamSidecar, LoadUpstream, UpstreamPath and the asserting UpstreamFake
This commit is contained in:
Jakub Zych
2026-10-03 19:39:58 +02:00
parent 06d6fdc758
commit 93b7142059
6 changed files with 848 additions and 19 deletions

View File

@@ -16,11 +16,15 @@ import (
"git.golem15.com/golem15/summercms/modules/compass"
)
// Result is a successful (including non-2xx, including 3xx) Fetch response.
// Result is a successful (including non-2xx, including 3xx) Fetch or Client
// response.
type Result struct {
Body []byte
ContentType string
StatusCode int
// Header holds every response header, for callers that read rate-limit
// or Retry-After values.
Header http.Header
}
var errPrivateIP = errors.New("private_ip")
@@ -55,24 +59,7 @@ func Fetch(ctx context.Context, rawURL string, policy Policy, cfg *compass.Confi
return nil, err
}
client := &http.Client{
Timeout: timeout,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
Transport: &http.Transport{
// User-supplied URLs must not be forwarded through HTTP_PROXY:
// the dial-time IP check would then see the proxy, not the target.
Proxy: nil,
DialContext: (&net.Dialer{
Timeout: timeout,
Control: dialControl(policy),
}).DialContext,
TLSClientConfig: policy.tlsConfig,
DisableKeepAlives: true,
ForceAttemptHTTP2: true,
},
}
client := newHTTPClient(newTransport(policy, timeout, false), timeout)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
if err != nil {
@@ -95,9 +82,39 @@ func Fetch(ctx context.Context, rawURL string, policy Policy, cfg *compass.Confi
Body: data,
ContentType: resp.Header.Get("Content-Type"),
StatusCode: resp.StatusCode,
Header: resp.Header,
}, nil
}
// newTransport builds the guarded transport for policy. keepAlives is off for
// one-shot Fetch calls and on for a reusable Client; the dial Control runs on
// every new connection either way.
func newTransport(policy Policy, timeout time.Duration, keepAlives bool) *http.Transport {
return &http.Transport{
// User-supplied URLs must not be forwarded through HTTP_PROXY:
// the dial-time IP check would then see the proxy, not the target.
Proxy: nil,
DialContext: (&net.Dialer{
Timeout: timeout,
Control: dialControl(policy),
}).DialContext,
TLSClientConfig: policy.tlsConfig,
DisableKeepAlives: !keepAlives,
ForceAttemptHTTP2: true,
}
}
// newHTTPClient wraps rt in an http.Client that never follows redirects.
func newHTTPClient(rt http.RoundTripper, timeout time.Duration) *http.Client {
return &http.Client{
Timeout: timeout,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
Transport: rt,
}
}
func resolveLimits(policy Policy, cfg *compass.Config) (int64, time.Duration, error) {
maxBytes := policy.MaxBytes
timeout := policy.Timeout