Limit Git HTTP resource usage
2e54bc699c2d22f9722c2489618ad5ebd1869ee1
1 parent
272b954a
git_http.go
+31 -8
| 5 | 5 | "net/http/cgi" |
|
| 6 | 6 | "os/exec" |
|
| 7 | 7 | "strings" |
|
| 8 | 8 | ) |
|
| 9 | 9 | ||
| 10 | + | const ( |
|
| 11 | + | gitHTTPDeltaBaseCacheLimit = "32m" |
|
| 12 | + | gitHTTPMaxConcurrent = 8 |
|
| 13 | + | ) |
|
| 14 | + | ||
| 15 | + | func gitHTTPEnv() []string { |
|
| 16 | + | return []string{ |
|
| 17 | + | "GIT_PROJECT_ROOT=", |
|
| 18 | + | "GIT_HTTP_EXPORT_ALL=1", |
|
| 19 | + | "GIT_CONFIG_COUNT=3", |
|
| 20 | + | "GIT_CONFIG_KEY_0=safe.directory", |
|
| 21 | + | "GIT_CONFIG_VALUE_0=*", |
|
| 22 | + | "GIT_CONFIG_KEY_1=pack.threads", |
|
| 23 | + | "GIT_CONFIG_VALUE_1=1", |
|
| 24 | + | "GIT_CONFIG_KEY_2=core.deltaBaseCacheLimit", |
|
| 25 | + | "GIT_CONFIG_VALUE_2=" + gitHTTPDeltaBaseCacheLimit, |
|
| 26 | + | } |
|
| 27 | + | } |
|
| 28 | + | ||
| 10 | 29 | // handleGitHTTP serves Git smart HTTP protocol requests by proxying to git-http-backend. |
|
| 11 | 30 | // This enables `git clone` over HTTPS. |
|
| 12 | 31 | func (s *server) handleGitHTTP(w http.ResponseWriter, r *http.Request, repo *RepoInfo) { |
|
| 32 | + | select { |
|
| 33 | + | case s.gitHTTPSlots <- struct{}{}: |
|
| 34 | + | defer func() { <-s.gitHTTPSlots }() |
|
| 35 | + | default: |
|
| 36 | + | w.Header().Set("Retry-After", "1") |
|
| 37 | + | http.Error(w, "too many concurrent Git operations", http.StatusServiceUnavailable) |
|
| 38 | + | return |
|
| 39 | + | } |
|
| 13 | 40 | gitHTTPBackend, err := exec.LookPath("git-http-backend") |
|
| 14 | 41 | if err != nil { |
|
| 15 | 42 | gitExecPath, err2 := exec.Command("git", "--exec-path").Output() |
|
| 16 | 43 | if err2 != nil { |
|
| 17 | 44 | http.Error(w, "git-http-backend not found", http.StatusInternalServerError) |
| 27 | 54 | pathInfo = strings.TrimPrefix(pathInfo, s.baseURL) |
|
| 28 | 55 | } |
|
| 29 | 56 | // Replace /<reponame>/ with /<reponame>.git/ |
|
| 30 | 57 | pathInfo = strings.Replace(pathInfo, "/"+repo.Name+"/", "/"+repo.Name+".git/", 1) |
|
| 31 | 58 | ||
| 59 | + | env := gitHTTPEnv() |
|
| 60 | + | env[0] += s.scanPath |
|
| 32 | 61 | handler := &cgi.Handler{ |
|
| 33 | - | Path: gitHTTPBackend, |
|
| 34 | - | Env: []string{ |
|
| 35 | - | "GIT_PROJECT_ROOT=" + s.scanPath, |
|
| 36 | - | "GIT_HTTP_EXPORT_ALL=1", |
|
| 37 | - | "GIT_CONFIG_COUNT=1", |
|
| 38 | - | "GIT_CONFIG_KEY_0=safe.directory", |
|
| 39 | - | "GIT_CONFIG_VALUE_0=*", |
|
| 40 | - | }, |
|
| 62 | + | Path: gitHTTPBackend, |
|
| 63 | + | Env: env, |
|
| 41 | 64 | InheritEnv: []string{"PATH"}, |
|
| 42 | 65 | } |
|
| 43 | 66 | ||
| 44 | 67 | // Override PATH_INFO for the CGI handler. |
|
| 45 | 68 | r2 := r.Clone(r.Context()) |
handler_test.go
+70 -0
| 97 | 97 | title: "Test Forge", |
|
| 98 | 98 | description: "test site description", |
|
| 99 | 99 | baseURL: "", |
|
| 100 | 100 | sshClonePrefix: "git@code.cloudhead.io:cloudhead/", |
|
| 101 | 101 | scanPath: tmpDir, |
|
| 102 | + | gitHTTPSlots: make(chan struct{}, gitHTTPMaxConcurrent), |
|
| 102 | 103 | } |
|
| 103 | 104 | } |
|
| 104 | 105 | ||
| 105 | 106 | func gitRun(t *testing.T, dir string, args ...string) { |
|
| 106 | 107 | t.Helper() |
| 1096 | 1097 | ||
| 1097 | 1098 | // =================================================================== |
|
| 1098 | 1099 | // isGitHTTPRequest |
|
| 1099 | 1100 | // =================================================================== |
|
| 1100 | 1101 | ||
| 1102 | + | func TestGitHTTPEnvLimitsReceivePackMemory(t *testing.T) { |
|
| 1103 | + | env := gitHTTPEnv() |
|
| 1104 | + | values := make(map[string]string, len(env)) |
|
| 1105 | + | for _, entry := range env { |
|
| 1106 | + | key, value, ok := strings.Cut(entry, "=") |
|
| 1107 | + | if !ok { |
|
| 1108 | + | t.Fatalf("invalid environment entry %q", entry) |
|
| 1109 | + | } |
|
| 1110 | + | values[key] = value |
|
| 1111 | + | } |
|
| 1112 | + | ||
| 1113 | + | if got := values["GIT_CONFIG_COUNT"]; got != "3" { |
|
| 1114 | + | t.Fatalf("GIT_CONFIG_COUNT = %q, want 3", got) |
|
| 1115 | + | } |
|
| 1116 | + | if got := values["GIT_CONFIG_KEY_1"]; got != "pack.threads" { |
|
| 1117 | + | t.Errorf("GIT_CONFIG_KEY_1 = %q, want pack.threads", got) |
|
| 1118 | + | } |
|
| 1119 | + | if got := values["GIT_CONFIG_VALUE_1"]; got != "1" { |
|
| 1120 | + | t.Errorf("GIT_CONFIG_VALUE_1 = %q, want 1", got) |
|
| 1121 | + | } |
|
| 1122 | + | if got := values["GIT_CONFIG_KEY_2"]; got != "core.deltaBaseCacheLimit" { |
|
| 1123 | + | t.Errorf("GIT_CONFIG_KEY_2 = %q, want core.deltaBaseCacheLimit", got) |
|
| 1124 | + | } |
|
| 1125 | + | if got := values["GIT_CONFIG_VALUE_2"]; got != gitHTTPDeltaBaseCacheLimit { |
|
| 1126 | + | t.Errorf("GIT_CONFIG_VALUE_2 = %q, want %s", got, gitHTTPDeltaBaseCacheLimit) |
|
| 1127 | + | } |
|
| 1128 | + | } |
|
| 1129 | + | ||
| 1130 | + | func TestGitHTTPRejectsExcessConcurrency(t *testing.T) { |
|
| 1131 | + | srv := testServer(t) |
|
| 1132 | + | for range gitHTTPMaxConcurrent { |
|
| 1133 | + | srv.gitHTTPSlots <- struct{}{} |
|
| 1134 | + | } |
|
| 1135 | + | ||
| 1136 | + | req := httptest.NewRequest(http.MethodPost, "/testrepo/git-receive-pack", nil) |
|
| 1137 | + | w := httptest.NewRecorder() |
|
| 1138 | + | srv.handleGitHTTP(w, req, srv.repos["testrepo"]) |
|
| 1139 | + | ||
| 1140 | + | if w.Code != http.StatusServiceUnavailable { |
|
| 1141 | + | t.Fatalf("status = %d, want %d", w.Code, http.StatusServiceUnavailable) |
|
| 1142 | + | } |
|
| 1143 | + | if got := w.Header().Get("Retry-After"); got != "1" { |
|
| 1144 | + | t.Errorf("Retry-After = %q, want 1", got) |
|
| 1145 | + | } |
|
| 1146 | + | } |
|
| 1147 | + | ||
| 1148 | + | func TestGitHTTPPush(t *testing.T) { |
|
| 1149 | + | srv := testServer(t) |
|
| 1150 | + | gitRun(t, "", "--git-dir", srv.repos["testrepo"].GitDir, "config", "http.receivepack", "true") |
|
| 1151 | + | ||
| 1152 | + | httpServer := httptest.NewServer(http.HandlerFunc(srv.route)) |
|
| 1153 | + | t.Cleanup(httpServer.Close) |
|
| 1154 | + | ||
| 1155 | + | workDir := filepath.Join(t.TempDir(), "work") |
|
| 1156 | + | gitRun(t, "", "clone", httpServer.URL+"/testrepo", workDir) |
|
| 1157 | + | gitRun(t, workDir, "config", "user.email", "test@example.com") |
|
| 1158 | + | gitRun(t, workDir, "config", "user.name", "Test User") |
|
| 1159 | + | if err := os.WriteFile(filepath.Join(workDir, "pushed.txt"), []byte("bounded push\n"), 0644); err != nil { |
|
| 1160 | + | t.Fatal(err) |
|
| 1161 | + | } |
|
| 1162 | + | gitRun(t, workDir, "add", "pushed.txt") |
|
| 1163 | + | gitRun(t, workDir, "commit", "-m", "Exercise HTTP receive-pack") |
|
| 1164 | + | gitRun(t, workDir, "push", "origin", "HEAD") |
|
| 1165 | + | ||
| 1166 | + | if _, err := srv.repos["testrepo"].Git.getBlob("HEAD", "pushed.txt"); err != nil { |
|
| 1167 | + | t.Fatalf("pushed blob is unavailable: %v", err) |
|
| 1168 | + | } |
|
| 1169 | + | } |
|
| 1170 | + | ||
| 1101 | 1171 | func TestIsGitHTTPRequest(t *testing.T) { |
|
| 1102 | 1172 | tests := []struct { |
|
| 1103 | 1173 | path string |
|
| 1104 | 1174 | query string |
|
| 1105 | 1175 | want bool |
file.go
+0 -0
old.go
+0 -0
new.go
+0 -0
image.png
+0 -0
file.go
+0 -0
a.go
+0 -0
b.go
+0 -0
main.go
+11 -2
| 8 | 8 | "net/http" |
|
| 9 | 9 | "os" |
|
| 10 | 10 | "path/filepath" |
|
| 11 | 11 | "sort" |
|
| 12 | 12 | "strings" |
|
| 13 | + | "time" |
|
| 13 | 14 | ) |
|
| 14 | 15 | ||
| 15 | 16 | type server struct { |
|
| 16 | 17 | repos map[string]*RepoInfo |
|
| 17 | 18 | sorted []string |
| 24 | 25 | scanPath string |
|
| 25 | 26 | username string |
|
| 26 | 27 | password string |
|
| 27 | 28 | dev bool |
|
| 28 | 29 | discussions bool |
|
| 30 | + | gitHTTPSlots chan struct{} |
|
| 29 | 31 | } |
|
| 30 | 32 | ||
| 31 | 33 | func main() { |
|
| 32 | 34 | listen := flag.String("listen", ":8080", "listen address") |
|
| 33 | 35 | scanPath := flag.String("scan-path", ".", "path to scan for git repos") |
| 91 | 93 | scanPath: abs, |
|
| 92 | 94 | username: *username, |
|
| 93 | 95 | password: *password, |
|
| 94 | 96 | dev: *dev, |
|
| 95 | 97 | discussions: *discussions, |
|
| 98 | + | gitHTTPSlots: make(chan struct{}, gitHTTPMaxConcurrent), |
|
| 96 | 99 | } |
|
| 97 | 100 | ||
| 98 | 101 | mux := http.NewServeMux() |
|
| 99 | 102 | mux.HandleFunc("/style.css", srv.serveCSS) |
|
| 100 | 103 | mux.HandleFunc("/radiant.svg", srv.serveLogo) |
| 105 | 108 | ||
| 106 | 109 | var handler http.Handler = mux |
|
| 107 | 110 | if srv.username != "" { |
|
| 108 | 111 | handler = srv.basicAuth(mux) |
|
| 109 | 112 | } |
|
| 110 | - | ||
| 113 | + | httpServer := &http.Server{ |
|
| 114 | + | Addr: *listen, |
|
| 115 | + | Handler: handler, |
|
| 116 | + | ReadHeaderTimeout: 10 * time.Second, |
|
| 117 | + | ReadTimeout: 30 * time.Minute, |
|
| 118 | + | IdleTimeout: 2 * time.Minute, |
|
| 119 | + | } |
|
| 111 | 120 | log.Printf("listening on %s (scanning %s, %d repos)", *listen, abs, len(repos)) |
|
| 112 | - | if err := http.ListenAndServe(*listen, handler); err != nil { |
|
| 121 | + | if err := httpServer.ListenAndServe(); err != nil { |
|
| 113 | 122 | log.Fatal(err) |
|
| 114 | 123 | } |
|
| 115 | 124 | } |
|
| 116 | 125 | ||
| 117 | 126 | func (s *server) basicAuth(next http.Handler) http.Handler { |