[ARVADOS] created: 16421a1aa8d420728b51d7cbb1b006a5613f8697
git at public.curoverse.com
git at public.curoverse.com
Fri Mar 13 18:03:49 EDT 2015
at 16421a1aa8d420728b51d7cbb1b006a5613f8697 (commit)
commit 16421a1aa8d420728b51d7cbb1b006a5613f8697
Author: Tom Clegg <tom at curoverse.com>
Date: Fri Mar 13 18:04:53 2015 -0400
5416: arv-git-httpd provides HTTP access to git repositories, using Arvados token authentication.
diff --git a/services/arv-git-httpd/.gitignore b/services/arv-git-httpd/.gitignore
new file mode 100644
index 0000000..1ae1045
--- /dev/null
+++ b/services/arv-git-httpd/.gitignore
@@ -0,0 +1 @@
+arv-git-httpd
diff --git a/services/arv-git-httpd/auth_handler.go b/services/arv-git-httpd/auth_handler.go
new file mode 100644
index 0000000..f182bca
--- /dev/null
+++ b/services/arv-git-httpd/auth_handler.go
@@ -0,0 +1,166 @@
+package main
+
+import (
+ "log"
+ "net/http"
+ "net/http/cgi"
+ "os"
+ "strings"
+ "sync"
+ "time"
+
+ "git.curoverse.com/arvados.git/sdk/go/arvadosclient"
+)
+
+func newArvadosClient() interface{} {
+ // MakeArvadosClient returns an error if token is unset (even
+ // though we don't need to do anything requiring
+ // authentication yet).
+ os.Setenv("ARVADOS_API_TOKEN", "xxx")
+ arv, err := arvadosclient.MakeArvadosClient()
+ if err != nil {
+ log.Println("MakeArvadosClient:", err)
+ return nil
+ }
+ return &arv
+}
+
+var connectionPool = &sync.Pool{New: newArvadosClient}
+
+type spyingResponseWriter struct {
+ http.ResponseWriter
+ wroteStatus *int
+}
+
+func (w spyingResponseWriter) WriteHeader(s int) {
+ *w.wroteStatus = s
+ w.ResponseWriter.WriteHeader(s)
+}
+
+type authHandler struct {
+ handler *cgi.Handler
+}
+
+func (h *authHandler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
+ var statusCode int
+ var statusText string
+ var username, password string
+ var repoName string
+ var wroteStatus int
+
+ w := spyingResponseWriter{wOrig, &wroteStatus}
+
+ defer func() {
+ if wroteStatus == 0 {
+ // Nobody has called WriteHeader yet: that must be our job.
+ w.WriteHeader(statusCode)
+ w.Write([]byte(statusText))
+ }
+ log.Println(quoteStrings(r.RemoteAddr, username, password, wroteStatus, statusText, repoName, r.URL.Path)...)
+ }()
+
+ // HTTP request username is logged, but unused. Password is an
+ // Arvados API token.
+ username, password, ok := r.BasicAuth()
+ if !ok || username == "" || password == "" {
+ statusCode, statusText = http.StatusUnauthorized, "no credentials provided"
+ w.Header().Add("WWW-Authenticate", "basic")
+ return
+ }
+
+ // Access to paths "/foo/bar.git/*" and "/foo/bar/.git/*" are
+ // protected by the permissions on the repository named
+ // "foo/bar".
+ pathParts := strings.SplitN(r.URL.Path[1:], ".git/", 2)
+ if len(pathParts) != 2 {
+ statusCode, statusText = http.StatusBadRequest, "bad request"
+ return
+ }
+ repoName = pathParts[0]
+ repoName = strings.TrimRight(repoName, "/")
+
+ // Regardless of whether the client asked for "/foo.git" or
+ // "/foo/.git", we choose whichever variant exists in our repo
+ // root. If neither exists, we won't even bother checking
+ // authentication.
+ rewrittenPath := ""
+ tryDirs := []string{
+ "/" + repoName + ".git",
+ "/" + repoName + "/.git",
+ }
+ for _, dir := range tryDirs {
+ if fileInfo, err := os.Stat(theConfig.Root + dir); err != nil {
+ if !os.IsNotExist(err) {
+ statusCode, statusText = http.StatusInternalServerError, err.Error()
+ return
+ }
+ } else if fileInfo.IsDir() {
+ rewrittenPath = dir + "/" + pathParts[1]
+ break
+ }
+ }
+ if rewrittenPath == "" {
+ statusCode, statusText = http.StatusNotFound, "not found"
+ return
+ }
+ r.URL.Path = rewrittenPath
+
+ arv, ok := connectionPool.Get().(*arvadosclient.ArvadosClient)
+ if !ok || arv == nil {
+ statusCode, statusText = http.StatusInternalServerError, "connection pool failed"
+ return
+ }
+ defer connectionPool.Put(arv)
+
+ // Ask API server whether the repository is readable using this token (by trying to read it!)
+ arv.ApiToken = password
+ reposFound := arvadosclient.Dict{}
+ if err := arv.List("repositories", arvadosclient.Dict{
+ "filters": [][]string{[]string{"name", "=", repoName}},
+ }, &reposFound); err != nil {
+ statusCode, statusText = http.StatusInternalServerError, err.Error()
+ return
+ }
+ if avail, ok := reposFound["items_available"].(float64); !ok {
+ statusCode, statusText = http.StatusInternalServerError, "bad list response from API"
+ return
+ } else if avail < 1 {
+ statusCode, statusText = http.StatusNotFound, "not found"
+ return
+ } else if avail > 1 {
+ statusCode, statusText = http.StatusInternalServerError, "name collision"
+ return
+ }
+ isWrite := strings.HasSuffix(r.URL.Path, "/git-receive-pack")
+ if !isWrite {
+ statusText = "read"
+ } else {
+ uuid := reposFound["items"].([]interface{})[0].(map[string]interface{})["uuid"].(string)
+ err := arv.Update("repositories", uuid, arvadosclient.Dict{
+ "repository": arvadosclient.Dict{
+ "modified_at": time.Now().String(),
+ },
+ }, &arvadosclient.Dict{})
+ if err != nil {
+ statusCode, statusText = http.StatusForbidden, err.Error()
+ return
+ }
+ statusText = "write"
+ }
+ handlerCopy := *h.handler
+ handlerCopy.Env = append(handlerCopy.Env, "REMOTE_USER="+r.RemoteAddr) // Should be username
+ handlerCopy.ServeHTTP(&w, r)
+}
+
+var escaper = strings.NewReplacer("\"", "\\\"", "\\", "\\\\", "\n", "\\n")
+
+// Transform strings so they are safer to write in logs (e.g.,
+// 'foo"bar' becomes '"foo\"bar"'). Non-string args are left alone.
+func quoteStrings(args ...interface{}) []interface{} {
+ for i, arg := range args {
+ if s, ok := arg.(string); ok {
+ args[i] = "\"" + escaper.Replace(s) + "\""
+ }
+ }
+ return args
+}
diff --git a/services/arv-git-httpd/doc.go b/services/arv-git-httpd/doc.go
new file mode 100644
index 0000000..21e8e48
--- /dev/null
+++ b/services/arv-git-httpd/doc.go
@@ -0,0 +1,7 @@
+/*
+arv-git-httpd provides authenticated access to Arvados-hosted git repositories.
+
+Example:
+ arv-git-httpd -address=:8000 -repo-root=/var/lib/arvados/git
+*/
+package main
diff --git a/services/arv-git-httpd/main.go b/services/arv-git-httpd/main.go
new file mode 100644
index 0000000..47758f6
--- /dev/null
+++ b/services/arv-git-httpd/main.go
@@ -0,0 +1,41 @@
+package main
+
+import (
+ "flag"
+ "log"
+ "os"
+)
+
+type config struct {
+ Addr string
+ GitCommand string
+ Root string
+}
+
+var theConfig *config
+
+func init() {
+ theConfig = &config{}
+ flag.StringVar(&theConfig.Addr, "address", "0.0.0.0:80",
+ "Address to listen on, \"host:port\".")
+ flag.StringVar(&theConfig.GitCommand, "git-command", "/usr/bin/git",
+ "Path to git executable. Each authenticated request will execute this program with a single argument, \"http-backend\".")
+ cwd, err := os.Getwd()
+ if err != nil {
+ log.Fatalln("Getwd():", err)
+ }
+ flag.StringVar(&theConfig.Root, "repo-root", cwd,
+ "Path to git repositories.")
+}
+
+func main() {
+ flag.Parse()
+ srv := &server{}
+ if err := srv.Start(); err != nil {
+ log.Fatal(err)
+ }
+ log.Println("Listening at", srv.Addr)
+ if err := srv.Wait(); err != nil {
+ log.Fatal(err)
+ }
+}
diff --git a/services/arv-git-httpd/server.go b/services/arv-git-httpd/server.go
new file mode 100644
index 0000000..393b6c4
--- /dev/null
+++ b/services/arv-git-httpd/server.go
@@ -0,0 +1,101 @@
+package main
+
+import (
+ "net"
+ "net/http"
+ "net/http/cgi"
+ "sync"
+ "time"
+)
+
+type server struct {
+ http.Server
+ Addr string // host:port where the server is listening.
+ err error
+ cond *sync.Cond
+ done bool
+ listener *net.TCPListener
+ wantDown bool
+}
+
+func (srv *server) Start() error {
+ gitHandler := &cgi.Handler{
+ Path: theConfig.GitCommand,
+ Dir: theConfig.Root,
+ Env: []string{
+ "GIT_PROJECT_ROOT=" + theConfig.Root,
+ "GIT_HTTP_EXPORT_ALL=",
+ },
+ InheritEnv: []string{"PATH"},
+ Args: []string{"http-backend"},
+ }
+
+ // The rest of the work here is essentially
+ // http.ListenAndServe() with two more features: (1) whoever
+ // called Start() can discover which address:port we end up
+ // listening to -- which makes listening on ":0" useful in
+ // test suites -- and (2) the server can be shut down without
+ // killing the process -- which is useful in test cases, and
+ // makes it possible to shut down gracefully on SIGTERM
+ // without killing active connections.
+
+ addr, err := net.ResolveTCPAddr("tcp", theConfig.Addr)
+ if err != nil {
+ return err
+ }
+ srv.listener, err = net.ListenTCP("tcp", addr)
+ if err != nil {
+ return err
+ }
+ srv.Addr = srv.listener.Addr().String()
+ mux := http.NewServeMux()
+ mux.Handle("/", &authHandler{gitHandler})
+ srv.Handler = mux
+
+ mutex := &sync.RWMutex{}
+ srv.cond = sync.NewCond(mutex.RLocker())
+ go func() {
+ err = srv.Serve(tcpKeepAliveListener{srv.listener})
+ if !srv.wantDown {
+ srv.err = err
+ }
+ mutex.Lock()
+ srv.done = true
+ srv.cond.Broadcast()
+ mutex.Unlock()
+ }()
+ return nil
+}
+
+// Wait returns when the server has shut down.
+func (srv *server) Wait() error {
+ srv.cond.L.Lock()
+ defer srv.cond.L.Unlock()
+ for !srv.done {
+ srv.cond.Wait()
+ }
+ return srv.err
+}
+
+// Close shuts down the server and returns when it has stopped.
+func (srv *server) Close() error {
+ srv.wantDown = true
+ srv.listener.Close()
+ return srv.Wait()
+}
+
+// tcpKeepAliveListener is copied from net/http because not exported.
+//
+type tcpKeepAliveListener struct {
+ *net.TCPListener
+}
+
+func (ln tcpKeepAliveListener) Accept() (c net.Conn, err error) {
+ tc, err := ln.AcceptTCP()
+ if err != nil {
+ return
+ }
+ tc.SetKeepAlive(true)
+ tc.SetKeepAlivePeriod(3 * time.Minute)
+ return tc, nil
+}
-----------------------------------------------------------------------
hooks/post-receive
--
More information about the arvados-commits
mailing list