tinyrelaycommit

Commit 7ab32ee79566

browse files at this commit | history

1diff --git a/deploy/tiny.Caddyfile b/deploy/tiny.Caddyfile
2index 7fd8ee7..922aa0d 100644
3--- a/deploy/tiny.Caddyfile
4+++ b/deploy/tiny.Caddyfile
5@@ -1,6 +1,9 @@
6 {
7 auto_https disable_redirects
8 admin 127.0.0.1:2019
9+ servers {
10+ enable_full_duplex
11+ }
12 }
13
14 http://tiny, http://tiny.tailbe516a.ts.net {
15diff --git a/internal/daemon/git_proxy_push_test.go b/internal/daemon/git_proxy_push_test.go
16new file mode 100644
17index 0000000..de54d2f
18--- /dev/null
19+++ b/internal/daemon/git_proxy_push_test.go
20@@ -0,0 +1,99 @@
21+package daemon
22+
23+import (
24+ "context"
25+ "crypto/rand"
26+ "io"
27+ "net/http/httptest"
28+ "net/http/httputil"
29+ "net/url"
30+ "os"
31+ "path/filepath"
32+ "strings"
33+ "testing"
34+ "time"
35+
36+ "github.com/FelineStateMachine/tinyrelay/internal/event"
37+ "github.com/FelineStateMachine/tinyrelay/internal/relay"
38+)
39+
40+// A reverse proxy without full duplex HTTP/1.1 discards the unread request
41+// body as soon as the backend starts responding. git-http-backend writes its
42+// headers before it reads the pack, so the relay must hold the response until
43+// git produces body bytes or a large push arrives truncated at the proxy.
44+func TestLargePushSurvivesHalfDuplexReverseProxy(t *testing.T) {
45+ ctx := context.Background()
46+ secret := strings.Repeat("0", 63) + "1"
47+ owner, err := event.PublicKey(secret)
48+ if err != nil {
49+ t.Fatal(err)
50+ }
51+ app, tenant := testTenant(t)
52+ p := tenant.Policy()
53+ p.Features.Grasp = true
54+ if err := tenant.applyPolicy(ctx, p); err != nil {
55+ t.Fatal(err)
56+ }
57+ const ownerNPub = "npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d"
58+ 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"}}}
59+ if err := event.Sign(&announcement, secret); err != nil {
60+ t.Fatal(err)
61+ }
62+ if _, err := tenant.router.Publish(ctx, announcement, relay.Session{PubKeys: []string{owner}}); err != nil {
63+ t.Fatal(err)
64+ }
65+
66+ work := filepath.Join(t.TempDir(), "work")
67+ gitTest(t, "init", "-b", "main", work)
68+ gitTest(t, "-C", work, "config", "user.email", "test@example.com")
69+ gitTest(t, "-C", work, "config", "user.name", "Test")
70+ blob := make([]byte, 1<<20)
71+ if _, err := rand.Read(blob); err != nil {
72+ t.Fatal(err)
73+ }
74+ if err := os.WriteFile(filepath.Join(work, "blob.bin"), blob, 0o600); err != nil {
75+ t.Fatal(err)
76+ }
77+ gitTest(t, "-C", work, "add", "blob.bin")
78+ gitTest(t, "-C", work, "commit", "-m", "big")
79+ sha := strings.TrimSpace(gitOutput(t, "-C", work, "rev-parse", "HEAD"))
80+ 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}}}
81+ if err := event.Sign(&state, secret); err != nil {
82+ t.Fatal(err)
83+ }
84+ if _, err := tenant.router.Publish(ctx, state, relay.Session{PubKeys: []string{owner}}); err != nil {
85+ t.Fatal(err)
86+ }
87+
88+ backend := httptest.NewServer(app)
89+ defer backend.Close()
90+ target, err := url.Parse(backend.URL)
91+ if err != nil {
92+ t.Fatal(err)
93+ }
94+ // Throttle the upload so the backend's headers arrive while the pack is
95+ // still streaming, as they do over a real network.
96+ proxy := httptest.NewServer(&httputil.ReverseProxy{Rewrite: func(request *httputil.ProxyRequest) {
97+ request.SetURL(target)
98+ request.Out.Body = &slowBody{ReadCloser: request.In.Body}
99+ }})
100+ defer proxy.Close()
101+
102+ // A small post buffer makes git stream the pack as a chunked HTTP/1.1 body.
103+ 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")
104+ clone := filepath.Join(t.TempDir(), "clone")
105+ gitTest(t, "clone", proxy.URL+"/r/main/"+ownerNPub+"/big.git", clone)
106+ if got := strings.TrimSpace(gitOutput(t, "-C", clone, "rev-parse", "HEAD")); got != sha {
107+ t.Fatalf("clone HEAD = %s, want %s", got, sha)
108+ }
109+}
110+
111+type slowBody struct{ io.ReadCloser }
112+
113+func (b *slowBody) Read(p []byte) (int, error) {
114+ if len(p) > 4096 {
115+ p = p[:4096]
116+ }
117+ time.Sleep(2 * time.Millisecond)
118+ return b.ReadCloser.Read(p)
119+}
120diff --git a/internal/gitrelay/gitrelay.go b/internal/gitrelay/gitrelay.go
121index fef9f16..4420c85 100644
122--- a/internal/gitrelay/gitrelay.go
123+++ b/internal/gitrelay/gitrelay.go
124@@ -2009,6 +2009,14 @@ func streamCGI(w http.ResponseWriter, controller *http.ResponseController, reade
125 responseHeaders.Add(k, strings.TrimSpace(v))
126 }
127 }
128+ // Hold the response until git produces its first body byte. HTTP/1.1
129+ // proxies without full duplex discard the unread request body once a
130+ // response starts, which would truncate a pack that is still uploading.
131+ // git-http-backend writes its headers before reading the request, but
132+ // writes body bytes only after it has consumed the pack.
133+ if _, err := reader.Peek(1); err != nil && !errors.Is(err, io.EOF) {
134+ return false, fmt.Errorf("read Git response body: %w", err)
135+ }
136 for k, values := range responseHeaders {
137 for _, value := range values {
138 w.Header().Add(k, value)

About

ownerc01503e8035f
visibilitypublic
head7ab32ee79566

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs