From 9ed11af60e78711bd801f7008192a4594d25cc08 Mon Sep 17 00:00:00 2001 From: Jacob Vosmaer <contact@jacobvosmaer.nl> Date: Sun, 23 Aug 2015 20:29:26 +0200 Subject: [PATCH] Put program initialization in a separate file --- README.md | 2 +- githandler.go | 286 ++++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 279 +----------------------------------------------- main_test.go | 2 +- 4 files changed, 293 insertions(+), 276 deletions(-) create mode 100644 githandler.go diff --git a/README.md b/README.md index 83ae98ec0298..2f2504198173 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ You can try out the Git server without authentication as follows: # Start a fake auth backend that allows everything/everybody go run support/say-yes.go & # Start gitlab-git-http-server -go run main.go /path/to/git-repos +go build && ./gitlab-git-http-server /path/to/git-repos ``` Now if you have a Git repository in `/path/to/git-repos/my-repo.git`, diff --git a/githandler.go b/githandler.go new file mode 100644 index 000000000000..6dae7a5ee5ea --- /dev/null +++ b/githandler.go @@ -0,0 +1,286 @@ +/* +The gitHandler type implements http.Handler. + +All code for handling Git HTTP requests is in this file. +*/ + +package main + +import ( + "compress/gzip" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "os/exec" + "path" + "strings" + "syscall" +) + +type gitHandler struct { + httpClient *http.Client + repoRoot string + authBackend string +} + +type gitService struct { + method string + suffix string + handleFunc func(gitEnv, string, string, http.ResponseWriter, *http.Request) + rpc string +} + +type gitEnv struct { + GL_ID string +} + +// Routing table +var gitServices = [...]gitService{ + gitService{"GET", "/info/refs", handleGetInfoRefs, ""}, + gitService{"POST", "/git-upload-pack", handlePostRPC, "git-upload-pack"}, + gitService{"POST", "/git-receive-pack", handlePostRPC, "git-receive-pack"}, +} + +func newGitHandler(repoRoot, authBackend string) *gitHandler { + return &gitHandler{&http.Client{}, repoRoot, authBackend} +} + +func (h *gitHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + var env gitEnv + var g gitService + + log.Print(r.Method, " ", r.URL) + + // Look for a matching Git service + foundService := false + for _, g = range gitServices { + if r.Method == g.method && strings.HasSuffix(r.URL.Path, g.suffix) { + foundService = true + break + } + } + if !foundService { + // The protocol spec in git/Documentation/technical/http-protocol.txt + // says we must return 403 if no matching service is found. + http.Error(w, "Forbidden", 403) + return + } + + // Ask the auth backend if the request is allowed, and what the + // user ID (GL_ID) is. + authResponse, err := h.doAuthRequest(r) + if err != nil { + fail500(w, err) + return + } + defer authResponse.Body.Close() + + if authResponse.StatusCode != 200 { + // The Git request is not allowed by the backend. Maybe the + // client needs to send HTTP Basic credentials. Forward the + // response from the auth backend to our client. This includes + // the 'WWW-Authentication' header that acts as a hint that + // Basic auth credentials are needed. + for k, v := range authResponse.Header { + w.Header()[k] = v + } + w.WriteHeader(authResponse.StatusCode) + io.Copy(w, authResponse.Body) + return + } + + // The auth backend validated the client request and told us who + // the user is according to them (GL_ID). We must extract this + // information from the auth response body. + dec := json.NewDecoder(authResponse.Body) + if err := dec.Decode(&env); err != nil { + fail500(w, err) + return + } + // Don't hog a TCP connection in CLOSE_WAIT, we can already close it now + authResponse.Body.Close() + + // About path traversal: the Go net/http HTTP server, or + // rather ServeMux, makes the following promise: "ServeMux + // also takes care of sanitizing the URL request path, redirecting + // any request containing . or .. elements to an equivalent + // .- and ..-free URL.". In other words, we may assume that + // r.URL.Path does not contain '/../', so there is no possibility + // of path traversal here. + repoPath := path.Join(h.repoRoot, strings.TrimSuffix(r.URL.Path, g.suffix)) + if !looksLikeRepo(repoPath) { + http.Error(w, "Not Found", 404) + return + } + + g.handleFunc(env, g.rpc, repoPath, w, r) +} + +func looksLikeRepo(p string) bool { + // If /path/to/foo.git/objects exists then let's assume it is a valid Git + // repository. + if _, err := os.Stat(path.Join(p, "objects")); err != nil { + log.Print(err) + return false + } + return true +} + +func (h *gitHandler) doAuthRequest(r *http.Request) (result *http.Response, err error) { + url := h.authBackend + r.URL.RequestURI() + authReq, err := http.NewRequest(r.Method, url, nil) + if err != nil { + return nil, err + } + // Forward all headers from our client to the auth backend. This includes + // HTTP Basic authentication credentials (the 'Authorization' header). + for k, v := range r.Header { + authReq.Header[k] = v + } + return h.httpClient.Do(authReq) +} + +func handleGetInfoRefs(env gitEnv, _ string, path string, w http.ResponseWriter, r *http.Request) { + rpc := r.URL.Query().Get("service") + if !(rpc == "git-upload-pack" || rpc == "git-receive-pack") { + // The 'dumb' Git HTTP protocol is not supported + http.Error(w, "Not Found", 404) + return + } + + // Prepare our Git subprocess + cmd := gitCommand(env, "git", subCommand(rpc), "--stateless-rpc", "--advertise-refs", path) + stdout, err := cmd.StdoutPipe() + if err != nil { + fail500(w, err) + return + } + defer stdout.Close() + if err := cmd.Start(); err != nil { + fail500(w, err) + return + } + defer cleanUpProcessGroup(cmd) // Ensure brute force subprocess clean-up + + // Start writing the response + w.Header().Add("Content-Type", fmt.Sprintf("application/x-%s-advertisement", rpc)) + w.Header().Add("Cache-Control", "no-cache") + w.WriteHeader(200) // Don't bother with HTTP 500 from this point on, just panic + if err := pktLine(w, fmt.Sprintf("# service=%s\n", rpc)); err != nil { + panic(err) + } + if err := pktFlush(w); err != nil { + panic(err) + } + if _, err := io.Copy(w, stdout); err != nil { + panic(err) + } + if err := cmd.Wait(); err != nil { + panic(err) + } +} + +func handlePostRPC(env gitEnv, rpc string, path string, w http.ResponseWriter, r *http.Request) { + var body io.Reader + var err error + + // The client request body may have been gzipped. + if r.Header.Get("Content-Encoding") == "gzip" { + body, err = gzip.NewReader(r.Body) + if err != nil { + fail500(w, err) + return + } + } else { + body = r.Body + } + + // Prepare our Git subprocess + cmd := gitCommand(env, "git", subCommand(rpc), "--stateless-rpc", path) + stdout, err := cmd.StdoutPipe() + if err != nil { + fail500(w, err) + return + } + defer stdout.Close() + stdin, err := cmd.StdinPipe() + if err != nil { + fail500(w, err) + return + } + defer stdin.Close() + if err := cmd.Start(); err != nil { + fail500(w, err) + return + } + defer cleanUpProcessGroup(cmd) // Ensure brute force subprocess clean-up + + // Write the client request body to Git's standard input + if _, err := io.Copy(stdin, body); err != nil { + fail500(w, err) + return + } + stdin.Close() + + // Start writing the response + w.Header().Add("Content-Type", fmt.Sprintf("application/x-%s-result", rpc)) + w.Header().Add("Cache-Control", "no-cache") + w.WriteHeader(200) // Don't bother with HTTP 500 from this point on, just panic + if _, err := io.Copy(w, stdout); err != nil { + panic(err) + } + if err := cmd.Wait(); err != nil { + panic(err) + } +} + +func fail500(w http.ResponseWriter, err error) { + http.Error(w, "Internal server error", 500) + log.Print(err) +} + +// Git subprocess helpers +func subCommand(rpc string) string { + return strings.TrimPrefix(rpc, "git-") +} + +func gitCommand(env gitEnv, name string, args ...string) *exec.Cmd { + cmd := exec.Command(name, args...) + // Start the command in its own process group (nice for signalling) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + // Explicitly set the environment for the Git command + cmd.Env = []string{ + fmt.Sprintf("PATH=%s", os.Getenv("PATH")), + fmt.Sprintf("GL_ID=%s", env.GL_ID), + } + return cmd +} + +func cleanUpProcessGroup(cmd *exec.Cmd) { + if cmd == nil { + return + } + + process := cmd.Process + if process != nil && process.Pid > 0 { + // Send SIGKILL (kill -9) to the process group of cmd + syscall.Kill(-process.Pid, syscall.SIGKILL) + } + + // reap our child process + cmd.Wait() +} + +// Git HTTP line protocol functions +func pktLine(w io.Writer, s string) error { + _, err := fmt.Fprintf(w, "%04x%s", len(s)+4, s) + return err +} + +func pktFlush(w io.Writer) error { + _, err := fmt.Fprint(w, "0000") + return err +} diff --git a/main.go b/main.go index b894f9cbe32e..e08da1afec8a 100644 --- a/main.go +++ b/main.go @@ -6,58 +6,30 @@ from Git clients that use the 'smart' Git HTTP protocol (git-upload-pack and git-receive-pack). It is intended to be deployed behind NGINX (for request routing and SSL termination) with access to a GitLab backend (for authentication and authorization) and local disk access -to Git repositories managed by GitLab. +to Git repositories managed by GitLab. In GitLab, this role was previously +performed by gitlab-grack. -This HTTP server replaces gitlab-grack. +This file contains the main() function. Actual Git HTTP requests are handled by +the gitHandler type, implemented in githandler.go. */ package main import ( - "compress/gzip" - "encoding/json" "flag" "fmt" - "io" "log" "net" "net/http" "os" - "os/exec" - "path" - "strings" "syscall" ) -type gitHandler struct { - httpClient *http.Client - repoRoot string - authBackend string -} - -type gitService struct { - method string - suffix string - handleFunc func(gitEnv, string, string, http.ResponseWriter, *http.Request) - rpc string -} - -type gitEnv struct { - GL_ID string -} - var Version string // Set at build time in the Makefile -// Routing table -var gitServices = [...]gitService{ - gitService{"GET", "/info/refs", handleGetInfoRefs, ""}, - gitService{"POST", "/git-upload-pack", handlePostRPC, "git-upload-pack"}, - gitService{"POST", "/git-receive-pack", handlePostRPC, "git-receive-pack"}, -} - func main() { printVersion := flag.Bool("version", false, "Print version and exit") listenAddr := flag.String("listenAddr", "localhost:8181", "Listen address for HTTP server") - listenNetwork := flag.String("listenNetwork", "tcp", "Listen 'network' (protocol)") + listenNetwork := flag.String("listenNetwork", "tcp", "Listen 'network' (tcp, tcp4, tcp6, unix)") listenUmask := flag.Int("listenUmask", 022, "Umask for Unix socket, default: 022") authBackend := flag.String("authBackend", "http://localhost:8080", "Authentication/authorization backend") flag.Usage = func() { @@ -97,244 +69,3 @@ func main() { http.Handle("/", newGitHandler(repoRoot, *authBackend)) log.Fatal(http.Serve(listener, nil)) } - -func newGitHandler(repoRoot, authBackend string) *gitHandler { - return &gitHandler{&http.Client{}, repoRoot, authBackend} -} - -func (h *gitHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - var env gitEnv - var g gitService - - log.Print(r.Method, " ", r.URL) - - // Look for a matching Git service - foundService := false - for _, g = range gitServices { - if r.Method == g.method && strings.HasSuffix(r.URL.Path, g.suffix) { - foundService = true - break - } - } - if !foundService { - // The protocol spec in git/Documentation/technical/http-protocol.txt - // says we must return 403 if no matching service is found. - http.Error(w, "Forbidden", 403) - return - } - - // Ask the auth backend if the request is allowed, and what the - // user ID (GL_ID) is. - authResponse, err := h.doAuthRequest(r) - if err != nil { - fail500(w, err) - return - } - defer authResponse.Body.Close() - - if authResponse.StatusCode != 200 { - // The Git request is not allowed by the backend. Maybe the - // client needs to send HTTP Basic credentials. Forward the - // response from the auth backend to our client. This includes - // the 'WWW-Authentication' header that acts as a hint that - // Basic auth credentials are needed. - for k, v := range authResponse.Header { - w.Header()[k] = v - } - w.WriteHeader(authResponse.StatusCode) - io.Copy(w, authResponse.Body) - return - } - - // The auth backend validated the client request and told us who - // the user is according to them (GL_ID). We must extract this - // information from the auth response body. - dec := json.NewDecoder(authResponse.Body) - if err := dec.Decode(&env); err != nil { - fail500(w, err) - return - } - // Don't hog a TCP connection in CLOSE_WAIT, we can already close it now - authResponse.Body.Close() - - // About path traversal: the Go net/http HTTP server, or - // rather ServeMux, makes the following promise: "ServeMux - // also takes care of sanitizing the URL request path, redirecting - // any request containing . or .. elements to an equivalent - // .- and ..-free URL.". In other words, we may assume that - // r.URL.Path does not contain '/../', so there is no possibility - // of path traversal here. - repoPath := path.Join(h.repoRoot, strings.TrimSuffix(r.URL.Path, g.suffix)) - if !looksLikeRepo(repoPath) { - http.Error(w, "Not Found", 404) - return - } - - g.handleFunc(env, g.rpc, repoPath, w, r) -} - -func looksLikeRepo(p string) bool { - // If /path/to/foo.git/objects exists then let's assume it is a valid Git - // repository. - if _, err := os.Stat(path.Join(p, "objects")); err != nil { - log.Print(err) - return false - } - return true -} - -func (h *gitHandler) doAuthRequest(r *http.Request) (result *http.Response, err error) { - url := h.authBackend + r.URL.RequestURI() - authReq, err := http.NewRequest(r.Method, url, nil) - if err != nil { - return nil, err - } - // Forward all headers from our client to the auth backend. This includes - // HTTP Basic authentication credentials (the 'Authorization' header). - for k, v := range r.Header { - authReq.Header[k] = v - } - return h.httpClient.Do(authReq) -} - -func handleGetInfoRefs(env gitEnv, _ string, path string, w http.ResponseWriter, r *http.Request) { - rpc := r.URL.Query().Get("service") - if !(rpc == "git-upload-pack" || rpc == "git-receive-pack") { - // The 'dumb' Git HTTP protocol is not supported - http.Error(w, "Not Found", 404) - return - } - - // Prepare our Git subprocess - cmd := gitCommand(env, "git", subCommand(rpc), "--stateless-rpc", "--advertise-refs", path) - stdout, err := cmd.StdoutPipe() - if err != nil { - fail500(w, err) - return - } - defer stdout.Close() - if err := cmd.Start(); err != nil { - fail500(w, err) - return - } - defer cleanUpProcessGroup(cmd) // Ensure brute force subprocess clean-up - - // Start writing the response - w.Header().Add("Content-Type", fmt.Sprintf("application/x-%s-advertisement", rpc)) - w.Header().Add("Cache-Control", "no-cache") - w.WriteHeader(200) // Don't bother with HTTP 500 from this point on, just panic - if err := pktLine(w, fmt.Sprintf("# service=%s\n", rpc)); err != nil { - panic(err) - } - if err := pktFlush(w); err != nil { - panic(err) - } - if _, err := io.Copy(w, stdout); err != nil { - panic(err) - } - if err := cmd.Wait(); err != nil { - panic(err) - } -} - -func handlePostRPC(env gitEnv, rpc string, path string, w http.ResponseWriter, r *http.Request) { - var body io.Reader - var err error - - // The client request body may have been gzipped. - if r.Header.Get("Content-Encoding") == "gzip" { - body, err = gzip.NewReader(r.Body) - if err != nil { - fail500(w, err) - return - } - } else { - body = r.Body - } - - // Prepare our Git subprocess - cmd := gitCommand(env, "git", subCommand(rpc), "--stateless-rpc", path) - stdout, err := cmd.StdoutPipe() - if err != nil { - fail500(w, err) - return - } - defer stdout.Close() - stdin, err := cmd.StdinPipe() - if err != nil { - fail500(w, err) - return - } - defer stdin.Close() - if err := cmd.Start(); err != nil { - fail500(w, err) - return - } - defer cleanUpProcessGroup(cmd) // Ensure brute force subprocess clean-up - - // Write the client request body to Git's standard input - if _, err := io.Copy(stdin, body); err != nil { - fail500(w, err) - return - } - stdin.Close() - - // Start writing the response - w.Header().Add("Content-Type", fmt.Sprintf("application/x-%s-result", rpc)) - w.Header().Add("Cache-Control", "no-cache") - w.WriteHeader(200) // Don't bother with HTTP 500 from this point on, just panic - if _, err := io.Copy(w, stdout); err != nil { - panic(err) - } - if err := cmd.Wait(); err != nil { - panic(err) - } -} - -func fail500(w http.ResponseWriter, err error) { - http.Error(w, "Internal server error", 500) - log.Print(err) -} - -// Git subprocess helpers -func subCommand(rpc string) string { - return strings.TrimPrefix(rpc, "git-") -} - -func gitCommand(env gitEnv, name string, args ...string) *exec.Cmd { - cmd := exec.Command(name, args...) - // Start the command in its own process group (nice for signalling) - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - // Explicitly set the environment for the Git command - cmd.Env = []string{ - fmt.Sprintf("PATH=%s", os.Getenv("PATH")), - fmt.Sprintf("GL_ID=%s", env.GL_ID), - } - return cmd -} - -func cleanUpProcessGroup(cmd *exec.Cmd) { - if cmd == nil { - return - } - - process := cmd.Process - if process != nil && process.Pid > 0 { - // Send SIGKILL (kill -9) to the process group of cmd - syscall.Kill(-process.Pid, syscall.SIGKILL) - } - - // reap our child process - cmd.Wait() -} - -// Git HTTP line protocol functions -func pktLine(w io.Writer, s string) error { - _, err := fmt.Fprintf(w, "%04x%s", len(s)+4, s) - return err -} - -func pktFlush(w io.Writer) error { - _, err := fmt.Fprint(w, "0000") - return err -} diff --git a/main_test.go b/main_test.go index 1749cafa31b0..4e64671b8514 100644 --- a/main_test.go +++ b/main_test.go @@ -117,7 +117,7 @@ func testAuthServer(code int, body string) *httptest.Server { } func startServerOrFail(t *testing.T, ts *httptest.Server) *exec.Cmd { - cmd := exec.Command("go", "run", "main.go", fmt.Sprintf("-authBackend=%s", ts.URL), fmt.Sprintf("-listenAddr=%s", servAddr), testRepoRoot) + cmd := exec.Command("go", "run", "main.go", "githandler.go", fmt.Sprintf("-authBackend=%s", ts.URL), fmt.Sprintf("-listenAddr=%s", servAddr), testRepoRoot) cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr -- GitLab