internal/sshd/pushlimit_test.go

v1.42.0
gitbay/internal/sshd/pushlimit_test.go history · blame · raw

275 lines · 8636 bytes

  1package sshd
  2
  3import (
  4	"crypto/ed25519"
  5	"crypto/rand"
  6	"encoding/pem"
  7	"fmt"
  8	"io"
  9	"net"
 10	"os"
 11	"os/exec"
 12	"path/filepath"
 13	"strings"
 14	"testing"
 15	"time"
 16
 17	"golang.org/x/crypto/ssh"
 18
 19	"gitbay.org/gitbay/internal/config"
 20	"gitbay.org/gitbay/internal/control"
 21	"gitbay.org/gitbay/internal/gitutil"
 22	"gitbay.org/gitbay/internal/packlimit"
 23	"gitbay.org/gitbay/internal/store"
 24)
 25
 26// A client that connects and sends nothing is cut at
 27// push_receive_timeout: the idle rule has not started, since no pack
 28// has begun. Its slot comes back.
 29func TestSilentPushKilledAtReceiveTimeout(t *testing.T) {
 30	cfg, st, alice := cloneFixture(t)
 31	cfg.Limits.PushIdle = "200ms"
 32	cfg.Limits.PushReceiveTimeout = "1s"
 33	key := store.SSHKey{ID: 1, Scope: "full", Fingerprint: "SHA256:test"}
 34	pushes := packlimit.New(1, 1, 0, time.Second)
 35	codec := make(chan int, 1)
 36	start := time.Now()
 37	go func() {
 38		codec <- Exec(cfg, st, nil, pushes, alice, key, control.Term{}, "git-receive-pack alice/app",
 39			silentStdin(t), io.Discard, io.Discard, nil, nil, nil)
 40	}()
 41	slotTaken(t, pushes)
 42	pushEnded(t, codec, pushes, 5*time.Second)
 43	if d := time.Since(start); d < time.Second {
 44		t.Fatalf("killed after %s, before push_receive_timeout", d)
 45	}
 46}
 47
 48// pktLine frames s as one pkt-line.
 49func pktLine(s string) string { return fmt.Sprintf("%04x%s", len(s)+4, s) }
 50
 51const zeroSHA = "0000000000000000000000000000000000000000"
 52
 53// Once the pack has begun, a client that goes silent is cut at
 54// push_idle, long before push_receive_timeout.
 55func TestPushKilledAtIdleOncePackStarts(t *testing.T) {
 56	cfg, st, alice := cloneFixture(t)
 57	cfg.Limits.PushIdle = "300ms"
 58	key := store.SSHKey{ID: 1, Scope: "full", Fingerprint: "SHA256:test"}
 59	pushes := packlimit.New(1, 1, 0, time.Second)
 60	r, w, err := os.Pipe()
 61	if err != nil {
 62		t.Fatal(err)
 63	}
 64	t.Cleanup(func() { r.Close(); w.Close() })
 65	io.WriteString(w, pktLine(zeroSHA+" "+strings.Repeat("1", 40)+" refs/heads/main\x00report-status\n")+"0000PA")
 66	codec := make(chan int, 1)
 67	start := time.Now()
 68	go func() {
 69		codec <- Exec(cfg, st, nil, pushes, alice, key, control.Term{}, "git-receive-pack alice/app",
 70			r, io.Discard, io.Discard, nil, nil, nil)
 71	}()
 72	slotTaken(t, pushes)
 73	// The signature split across two writes still arms the rule.
 74	time.Sleep(100 * time.Millisecond)
 75	io.WriteString(w, "CK")
 76	pushEnded(t, codec, pushes, 5*time.Second)
 77	if d := time.Since(start); d > 5*time.Second {
 78		t.Fatalf("killed after %s", d)
 79	}
 80}
 81
 82// A client silent for longer than push_idle between its commands and
 83// its pack, as while pack-objects compresses a large first push, still
 84// completes.
 85func TestPushSilentBeforePackCompletes(t *testing.T) {
 86	cfg, st, alice := cloneFixture(t)
 87	cfg.Limits.PushIdle = "300ms"
 88	key := store.SSHKey{ID: 1, Scope: "full", Fingerprint: "SHA256:test"}
 89	pushes := packlimit.New(1, 1, 0, time.Second)
 90
 91	src := t.TempDir()
 92	git := func(stdin string, args ...string) []byte {
 93		t.Helper()
 94		cmd := exec.Command("git", append([]string{"-C", src, "-c", "user.name=t", "-c", "user.email=t@t"}, args...)...)
 95		cmd.Stdin = strings.NewReader(stdin)
 96		out, err := cmd.Output()
 97		if err != nil {
 98			t.Fatalf("git %v: %v", args, err)
 99		}
100		return out
101	}
102	git("", "init", "-q", "-b", "main")
103	if err := os.WriteFile(filepath.Join(src, "README"), []byte("x\n"), 0o644); err != nil {
104		t.Fatal(err)
105	}
106	git("", "add", ".")
107	git("", "commit", "-q", "-m", "one")
108	sha := strings.TrimSpace(string(git("", "rev-parse", "HEAD")))
109	pack := git(sha+"\n", "pack-objects", "--revs", "--stdout", "-q")
110
111	pr, pw := io.Pipe()
112	go func() {
113		io.WriteString(pw, pktLine(zeroSHA+" "+sha+" refs/heads/main\x00report-status\n")+"0000")
114		time.Sleep(time.Second)
115		pw.Write(pack)
116		pw.Close()
117	}()
118	var errOut strings.Builder
119	if code := Exec(cfg, st, nil, pushes, alice, key, control.Term{}, "git-receive-pack alice/app",
120		pr, io.Discard, &errOut, nil, nil, nil); code != 0 {
121		t.Fatalf("exit %d: %s", code, errOut.String())
122	}
123	out, err := exec.Command("git", "-C", control.RepoDir(cfg.Server.Root, "alice", "app"), "rev-parse", "refs/heads/main").Output()
124	if err != nil || strings.TrimSpace(string(out)) != sha {
125		t.Fatalf("main is %q (%v), want %s", out, err, sha)
126	}
127}
128
129// A client that trickles bytes stays clear of push_idle but is cut when
130// pre-receive has not started push_receive_timeout after the slot.
131func TestTricklingPushKilledAtReceiveTimeout(t *testing.T) {
132	cfg, st, alice := cloneFixture(t)
133	cfg.Limits.PushIdle = "300ms"
134	cfg.Limits.PushReceiveTimeout = "1s"
135	key := store.SSHKey{ID: 1, Scope: "full", Fingerprint: "SHA256:test"}
136	pushes := packlimit.New(1, 1, 0, time.Second)
137	r, w, err := os.Pipe()
138	if err != nil {
139		t.Fatal(err)
140	}
141	t.Cleanup(func() { r.Close(); w.Close() })
142	stop := make(chan struct{})
143	defer close(stop)
144	go func() {
145		// One long pkt-line whose payload arrives a byte at a time.
146		if _, err := io.WriteString(w, "fff0"); err != nil {
147			return
148		}
149		for {
150			select {
151			case <-stop:
152				return
153			case <-time.After(50 * time.Millisecond):
154				if _, err := w.Write([]byte("a")); err != nil {
155					return
156				}
157			}
158		}
159	}()
160	codec := make(chan int, 1)
161	start := time.Now()
162	go func() {
163		codec <- Exec(cfg, st, nil, pushes, alice, key, control.Term{}, "git-receive-pack alice/app",
164			r, io.Discard, io.Discard, nil, nil, nil)
165	}()
166	slotTaken(t, pushes)
167	pushEnded(t, codec, pushes, 5*time.Second)
168	if d := time.Since(start); d < time.Second {
169		t.Fatalf("killed after %s, before push_receive_timeout", d)
170	}
171}
172
173// A real push whose post-receive outlasts push_idle completes: git's
174// side-band keepalives count as bytes to the client.
175func TestPushSurvivesLongPostReceive(t *testing.T) {
176	if _, err := exec.LookPath("ssh"); err != nil {
177		t.Skip("no ssh client")
178	}
179	root := t.TempDir()
180	st, err := store.Open(filepath.Join(root, "gitbay.db"))
181	if err != nil {
182		t.Fatal(err)
183	}
184	t.Cleanup(func() { st.Close() })
185	if err := st.MigrateUp(); err != nil {
186		t.Fatal(err)
187	}
188	uid, err := st.CreateUser("alice", false)
189	if err != nil {
190		t.Fatal(err)
191	}
192	if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil {
193		t.Fatal(err)
194	}
195	hooks := t.TempDir()
196	if err := os.WriteFile(filepath.Join(hooks, "post-receive"), []byte("#!/bin/sh\nsleep 4\n"), 0o755); err != nil {
197		t.Fatal(err)
198	}
199	if err := gitutil.InitBare(control.RepoDir(root, "alice", "app"), "main", hooks); err != nil {
200		t.Fatal(err)
201	}
202
203	pub, priv, err := ed25519.GenerateKey(rand.Reader)
204	if err != nil {
205		t.Fatal(err)
206	}
207	sshPub, err := ssh.NewPublicKey(pub)
208	if err != nil {
209		t.Fatal(err)
210	}
211	if err := st.AddSSHKey(uid, ssh.FingerprintSHA256(sshPub), sshPub.Type(), sshPub.Marshal(), "full", "test"); err != nil {
212		t.Fatal(err)
213	}
214	block, err := ssh.MarshalPrivateKey(priv, "")
215	if err != nil {
216		t.Fatal(err)
217	}
218	keyFile := filepath.Join(t.TempDir(), "id_ed25519")
219	if err := os.WriteFile(keyFile, pem.EncodeToMemory(block), 0o600); err != nil {
220		t.Fatal(err)
221	}
222
223	cfg := config.Default()
224	cfg.Server.Root = root
225	cfg.Limits.PushIdle = "2s"
226	pushes := packlimit.New(1, 1, 0, time.Second)
227	srv, err := New(cfg, st, nil, pushes)
228	if err != nil {
229		t.Fatal(err)
230	}
231	ln, err := net.Listen("tcp", "127.0.0.1:0")
232	if err != nil {
233		t.Fatal(err)
234	}
235	go srv.Serve(ln)
236	t.Cleanup(func() { ln.Close() })
237	port := ln.Addr().(*net.TCPAddr).Port
238
239	src := t.TempDir()
240	git := func(args ...string) string {
241		t.Helper()
242		cmd := exec.Command("git", args...)
243		cmd.Dir = src
244		cmd.Env = append(os.Environ(),
245			"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_NOSYSTEM=1",
246			"GIT_AUTHOR_NAME=a", "GIT_AUTHOR_EMAIL=a@example.test",
247			"GIT_COMMITTER_NAME=a", "GIT_COMMITTER_EMAIL=a@example.test",
248			fmt.Sprintf("GIT_SSH_COMMAND=ssh -F /dev/null -i %s -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -p %d", keyFile, port))
249		out, err := cmd.CombinedOutput()
250		if err != nil {
251			t.Fatalf("git %v: %v\n%s", args, err, out)
252		}
253		return string(out)
254	}
255	git("init", "-q", "-b", "main")
256	if err := os.WriteFile(filepath.Join(src, "README"), []byte("x\n"), 0o644); err != nil {
257		t.Fatal(err)
258	}
259	git("add", ".")
260	git("commit", "-q", "-m", "one")
261	start := time.Now()
262	git("push", "git@127.0.0.1:alice/app", "main")
263	if d := time.Since(start); d < 4*time.Second {
264		t.Fatalf("push took %s; the post-receive did not run", d)
265	}
266	out, err := exec.Command("git", "-C", control.RepoDir(root, "alice", "app"), "rev-parse", "refs/heads/main").CombinedOutput()
267	if err != nil || strings.TrimSpace(string(out)) == "" {
268		t.Fatalf("main not pushed: %v %s", err, out)
269	}
270	r, err := pushes.Acquire(nil, "elsewhere")
271	if err != nil {
272		t.Fatalf("slot not released: %v", err)
273	}
274	r()
275}