git-http-backend writes its CGI headers before reading the request. A reverse proxy without full duplex HTTP/1.1 closes the unread client body as soon as the backend responds, so a pack still streaming through the proxy arrived truncated and receive-pack reported an unpacker error. Wait for the first response body byte before writing headers, add a regression test with a throttled half-duplex proxy, and enable full duplex in the reference Caddyfile as well.
- commit
- 7ab32ee7956634513df53eeac31647842a644b60
- parent
- 573e6fba3dbc5a3dec10f9d51442835f44ff124f
- author
- Dami <dami.etoile@gmail.com>
| M | deploy/ | 3 | + |
| A | internal/ | 99 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 8 | ++ |
@@ -0,0 +1,99 @@
1+package daemon2+3+import (4+ "context"5+ "crypto/rand"6+ "io"7+ "net/http/httptest"8+ "net/http/httputil"9+ "net/url"10+ "os"11+ "path/filepath"12+ "strings"13+ "testing"14+ "time"15+16+ "github.com/FelineStateMachine/tinyrelay/internal/event"17+ "github.com/FelineStateMachine/tinyrelay/internal/relay"18+)19+20+// A reverse proxy without full duplex HTTP/1.1 discards the unread request21+// body as soon as the backend starts responding. git-http-backend writes its22+// headers before it reads the pack, so the relay must hold the response until23+// git produces body bytes or a large push arrives truncated at the proxy.24+func TestLargePushSurvivesHalfDuplexReverseProxy(t *testing.T) {25+ ctx := context.Background()26+ secret := strings.Repeat("0", 63) + "1"27+ owner, err := event.PublicKey(secret)28+ if err != nil {29+ t.Fatal(err)30+ }31+ app, tenant := testTenant(t)32+ p := tenant.Policy()33+ p.Features.Grasp = true34+ if err := tenant.applyPolicy(ctx, p); err != nil {35+ t.Fatal(err)36+ }37+ const ownerNPub = "npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d"38+ announcement := event.Event{Kind: event.KIND_REPO, CreatedAt: time.Now().Unix(), Tags: [][]string{{"d", "big"}, {"clone", "http://relay.test/" + ownerNPub + "/big.git"}, {"relays", "ws://relay.test"}}}39+ if err := event.Sign(&announcement, secret); err != nil {40+ t.Fatal(err)41+ }42+ if _, err := tenant.router.Publish(ctx, announcement, relay.Session{PubKeys: []string{owner}}); err != nil {43+ t.Fatal(err)44+ }45+46+ work := filepath.Join(t.TempDir(), "work")47+ gitTest(t, "init", "-b", "main", work)48+ gitTest(t, "-C", work, "config", "user.email", "test@example.com")49+ gitTest(t, "-C", work, "config", "user.name", "Test")50+ blob := make([]byte, 1<<20)51+ if _, err := rand.Read(blob); err != nil {52+ t.Fatal(err)53+ }54+ if err := os.WriteFile(filepath.Join(work, "blob.bin"), blob, 0o600); err != nil {55+ t.Fatal(err)56+ }57+ gitTest(t, "-C", work, "add", "blob.bin")58+ gitTest(t, "-C", work, "commit", "-m", "big")59+ sha := strings.TrimSpace(gitOutput(t, "-C", work, "rev-parse", "HEAD"))60+ state := event.Event{Kind: event.KIND_REPO_STATE, CreatedAt: time.Now().Unix(), Tags: [][]string{{"d", "big"}, {"HEAD", "ref: refs/heads/main"}, {"refs/heads/main", sha}}}61+ if err := event.Sign(&state, secret); err != nil {62+ t.Fatal(err)63+ }64+ if _, err := tenant.router.Publish(ctx, state, relay.Session{PubKeys: []string{owner}}); err != nil {65+ t.Fatal(err)66+ }67+68+ backend := httptest.NewServer(app)69+ defer backend.Close()70+ target, err := url.Parse(backend.URL)71+ if err != nil {72+ t.Fatal(err)73+ }74+ // Throttle the upload so the backend's headers arrive while the pack is75+ // still streaming, as they do over a real network.76+ proxy := httptest.NewServer(&httputil.ReverseProxy{Rewrite: func(request *httputil.ProxyRequest) {77+ request.SetURL(target)78+ request.Out.Body = &slowBody{ReadCloser: request.In.Body}79+ }})80+ defer proxy.Close()81+82+ // A small post buffer makes git stream the pack as a chunked HTTP/1.1 body.83+ gitTest(t, "-C", work, "-c", "http.postBuffer=1024", "-c", "http.version=HTTP/1.1", "push", proxy.URL+"/r/main/"+ownerNPub+"/big.git", "refs/heads/main:refs/heads/main")84+ clone := filepath.Join(t.TempDir(), "clone")85+ gitTest(t, "clone", proxy.URL+"/r/main/"+ownerNPub+"/big.git", clone)86+ if got := strings.TrimSpace(gitOutput(t, "-C", clone, "rev-parse", "HEAD")); got != sha {87+ t.Fatalf("clone HEAD = %s, want %s", got, sha)88+ }89+}90+91+type slowBody struct{ io.ReadCloser }92+93+func (b *slowBody) Read(p []byte) (int, error) {94+ if len(p) > 4096 {95+ p = p[:4096]96+ }97+ time.Sleep(2 * time.Millisecond)98+ return b.ReadCloser.Read(p)99+}@@ -2009,6 +2009,14 @@ func streamCGI(w http.ResponseWriter, controller *http.ResponseController, reade
20092009 responseHeaders.Add(k, strings.TrimSpace(v))20102010 }20112011 }2012+ // Hold the response until git produces its first body byte. HTTP/1.12013+ // proxies without full duplex discard the unread request body once a2014+ // response starts, which would truncate a pack that is still uploading.2015+ // git-http-backend writes its headers before reading the request, but2016+ // writes body bytes only after it has consumed the pack.2017+ if _, err := reader.Peek(1); err != nil && !errors.Is(err, io.EOF) {2018+ return false, fmt.Errorf("read Git response body: %w", err)2019+ }20122020 for k, values := range responseHeaders {20132021 for _, value := range values {20142022 w.Header().Add(k, value)