caddy-git-server

Caddy module that provides a git server.

package gitserver

import (
	"bytes"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"strconv"
	"strings"

	"github.com/caddyserver/caddy/v2/modules/caddyhttp"
	"github.com/go-git/go-git/v5"
	"github.com/go-git/go-git/v5/plumbing"
	"github.com/go-git/go-git/v5/plumbing/format/packfile"
	"github.com/go-git/go-git/v5/plumbing/format/pktline"
	"github.com/go-git/go-git/v5/plumbing/object"
	"go.uber.org/zap"
)

// Serve a git client
func (gs *GitServer) serveGitClient(repoPath string, w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {

	// Log the clone attempt
	gs.logger.Info("serving git client",
		zap.String("path", r.RequestURI),
		zap.String("method", r.Method),
		zap.String("git_repo", repoPath),
		zap.String("git_protocol", r.Header.Get("Git-Protocol")),
		zap.String("git_client", r.UserAgent()),
	)

	// Only serve v0 because we dont support v2 yet
	return gs.serveGitV0(repoPath, w, r, next)

	// Serve v0 or v2 based on header
	// if gs.Protocol == "smart" || (r.Header.Get("Git-Protocol") == "version=2" && gs.Protocol == "both") {
	// 	return gs.serveGitV2(repoPath, w, r, next)
	// } else if gs.Protocol == "dumb" || gs.Protocol == "both" {
	// 	// Serve dumb v0 protocol
	// } else {
	// 	return nil
	// }
}

// Serve dumb git client files. These are generated on-the-fly
func (gs *GitServer) serveGitV0(repoPath string, w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {

	// repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer)

	// root := repl.ReplaceAll(gs.Root, ".")

	// Detect 'info/refs' and generate and serve
	if strings.HasSuffix(r.URL.Path, "info/refs") {
		// Try to open repo
		repo, err := git.PlainOpen(repoPath)
		if err != nil {
			return caddyhttp.Error(http.StatusInternalServerError, fmt.Errorf("could not load repository"))
		}

		// // Log the clone attempt
		// gs.logger.Info("git clone attempt",
		// 	zap.String("path", r.RequestURI),
		// 	zap.String("git_repo", repoPath),
		// 	zap.String("git_protocol", r.Header.Get("Git-Protocol")),
		// 	zap.String("git_client", r.UserAgent()),
		// )

		var refs []string

		// Collect all heads in repo
		repoHeads, err := repo.Branches()
		if err != nil {
			return caddyhttp.Error(http.StatusInternalServerError, err)
		}
		// Write heads to connection
		repoHeads.ForEach(func(r *plumbing.Reference) error {
			fmt.Fprintf(w, "%s\t%s\n", r.Hash().String(), r.Name().String())
			refs = append(refs, r.String())
			return nil
		})

		// Collect all tags in repo
		repoTags, err := repo.Tags()
		if err != nil {
			return caddyhttp.Error(http.StatusInternalServerError, err)
		}
		// Write tags to connection
		repoTags.ForEach(func(r *plumbing.Reference) error {
			fmt.Fprintf(w, "%s\t%s\n", r.Hash().String(), r.Name().String())
			refs = append(refs, r.String())
			return nil
		})

		gs.logger.Debug("generating dumb info/refs",
			zap.String("git_repo", repoPath),
			zap.String("req_path", r.URL.Path),
			zap.String("refs", strings.Join(refs, ",")),
		)

		// The approach below is without a git library //
		// // Find all ref files in GIT_DIR/refs/*/*
		// refPath := filepath.Join(gitDir, "refs/*/*")
		// refFiles, err := filepath.Glob(refPath)
		// if err != nil {
		// 	return err
		// }

		// // Generate 'info/refs'
		// var infoRefs string
		// for _, s := range refFiles {
		// 	refDirs, refName := filepath.Split(s)
		// 	_, refDir := filepath.Split(strings.TrimSuffix(refDirs, "/"))
		// 	refName = filepath.Join("refs", refDir, refName)
		// 	refHash, err := os.ReadFile(s)
		// 	if err != nil {
		// 		return err
		// 	}
		// 	infoRefs += strings.TrimSpace(string(refHash)) + "\t" + refName + "\n"
		// }

		// // Write info/refs to connection and close it
		// fmt.Fprintf(w, "%s", infoRefs)
		//                                             //
		return nil
	}

	// Detect 'objects/info/packs' and generate and serve
	if strings.HasSuffix(r.URL.Path, "objects/info/packs") {

		// Try to open repo
		_, err := git.PlainOpen(repoPath)
		if err != nil {
			return caddyhttp.Error(http.StatusInternalServerError, err)
		}

		// Get packs in repo
		packFiles, err := filepath.Glob(filepath.Join(repoPath, "objects/pack/*.pack"))
		if err != nil {
			return caddyhttp.Error(http.StatusInternalServerError, err)
		}

		// Write pack file response
		for _, packFile := range packFiles {
			fmt.Fprintf(w, "P %s\n", filepath.Base(packFile))
		}

		return nil
	}

	// Serve the file if it exists
	return gs.FileServer.ServeHTTP(w, r, next)
}

// Serve git protocol v2
func (gs *GitServer) serveGitV2(repoPath string, w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {

	if strings.HasSuffix(r.URL.Path, "/info/refs") {
		// Initial Client request to "%GIT_URL/info/refs?service=git-upload-pack"
		// Return advertised capabilities
		return gs.serveGitV2Capabilities(w, r)
	}

	// Client is requesting command
	if strings.HasSuffix(r.URL.Path, "/git-upload-pack") {

		// Read command
		pktLenBytes := make([]byte, 4)
		r.Body.Read(pktLenBytes)
		commandLen64, err := strconv.ParseUint(string(pktLenBytes), 16, 32)
		if err != nil {
			return caddyhttp.Error(http.StatusInternalServerError, err)
		}
		commandBytes := make([]byte, commandLen64-4)
		r.Body.Read(commandBytes)
		commandBytes = bytes.TrimSpace(commandBytes)
		println(string(commandBytes))

		// Read capability list until the delimeter packet is reached ("0001")
		var pktLen uint64
		r.Body.Read(pktLenBytes)
		pktLen, err = strconv.ParseUint(string(pktLenBytes), 16, 32)
		if err != nil {
			return caddyhttp.Error(http.StatusInternalServerError, err)
		}
		for pktLen != 1 {
			capabilityBytes := make([]byte, pktLen-4)
			r.Body.Read(capabilityBytes)
			fmt.Printf("capability: %s\n", capabilityBytes)
			r.Body.Read(pktLenBytes)
			pktLen, err = strconv.ParseUint(string(pktLenBytes), 16, 32)
			if err != nil {
				return caddyhttp.Error(http.StatusInternalServerError, err)
			}
		}

		// Hand off to command handler
		switch string(commandBytes[8:]) {
		case "ls-refs":
			return gs.serveGitV2LsRefs(repoPath, w, r)

		case "fetch":
			return gs.serveGitV2Fetch(repoPath, w, r)

		}

		println("Remaining body:")
		bodyBytes := make([]byte, 1024)
		r.Body.Read(bodyBytes)
		println(string(bodyBytes))
	}

	return nil
}

// Serve v2 protocol capabilities
func (gs *GitServer) serveGitV2Capabilities(w http.ResponseWriter, r *http.Request) error {

	// Set content type and cache control headers
	w.Header().Add("Content-Type", "application/x-git-upload-pack-advertisement")
	w.Header().Add("Cache-Control", "no-cache")

	e := pktline.NewEncoder(w)

	// Write v2 version string
	e.EncodeString("version 2")
	// fmt.Fprint(w, "000eversion 2\n")

	// Write capability list
	e.EncodeString(
		"agent=caddy-git-server/0.0.0",
		"ls-refs",
		"fetch",
	)
	// fmt.Fprint(w, "0021agent=caddy-git-server/0.0.0\n")
	// fmt.Fprint(w, "000cls-refs\n")
	// fmt.Fprint(w, "000afetch\n")

	// Write flush packet
	return e.Flush()
	// fmt.Fprint(w, "0000")

	// return nil
}

// Serve v2 protocol ls-refs command
func (gs *GitServer) serveGitV2LsRefs(repoPath string, w http.ResponseWriter, r *http.Request) error {

	// Try to open repo
	repo, err := git.PlainOpen(repoPath)
	if err != nil {
		return caddyhttp.Error(http.StatusInternalServerError, fmt.Errorf("could not load repository"))
	}

	var refs []string

	// Collect all heads in repo
	repoHeads, err := repo.Branches()
	if err != nil {
		return caddyhttp.Error(http.StatusInternalServerError, err)
	}
	// Write heads to connection
	repoHeads.ForEach(func(r *plumbing.Reference) error {
		tknLen := len(r.Hash().String()) + len(r.Name().String()) + 6 // 4 extra for length, 1 for space, one for newline
		fmt.Fprintf(w, "%.4x%s %s\n", tknLen, r.Hash().String(), r.Name().String())
		refs = append(refs, r.String())
		return nil
	})

	// Collect all tags in repo
	repoTags, err := repo.Tags()
	if err != nil {
		return caddyhttp.Error(http.StatusInternalServerError, err)
	}
	// Write tags to connection
	repoTags.ForEach(func(r *plumbing.Reference) error {
		tknLen := len(r.Hash().String()) + len(r.Name().String()) + 6 // 4 extra for length, 1 for space, one for newline
		fmt.Fprintf(w, "%.4x%s %s\n", tknLen, r.Hash().String(), r.Name().String())
		refs = append(refs, r.String())
		return nil
	})
	// println(strings.Join(refs, ", "))

	// Write flush packet
	fmt.Fprint(w, "0000")

	return nil
}

func (gs *GitServer) serveGitV2Fetch(repoPath string, w http.ResponseWriter, r *http.Request) error {

	e := pktline.NewEncoder(w)

	// Print arguments and get list of wanted object hashed
	var pktLen uint64
	var wanted []string
	var objHashes []plumbing.Hash
	var done = false
	pktLenBytes := make([]byte, 4)
	r.Body.Read(pktLenBytes)
	pktLen, err := strconv.ParseUint(string(pktLenBytes), 16, 32)
	if err != nil {
		return caddyhttp.Error(http.StatusInternalServerError, err)
	}
	for pktLen != 0 {

		// Read packet line
		argBytes := make([]byte, pktLen-4)
		r.Body.Read(argBytes)
		argBytes = bytes.TrimSpace(argBytes)
		switch string(argBytes[:4]) {
		case "want":
			wanted = append(wanted, string(argBytes[5:]))
			objHashes = append(objHashes, plumbing.NewHash(string(argBytes[5:])))
			fmt.Printf("want: %s", argBytes[5:])
			if plumbing.IsHash(string(argBytes[5:])) {
				fmt.Println(" valid")
			} else {
				fmt.Println(" invalid!")
			}
		case "done":
			done = true
			fmt.Print("done\n")
		default:
			fmt.Printf("argument: %s\n", argBytes)
		}

		// Read next packet line length
		r.Body.Read(pktLenBytes)
		pktLen, err = strconv.ParseUint(string(pktLenBytes), 16, 32)
		if err != nil {
			return caddyhttp.Error(http.StatusInternalServerError, err)
		}
	}

	// Return ACKs if not done
	if !done {
		e.EncodeString("acknowledgements")
		for _, h := range wanted {
			e.Encodef("ACK %s", h)
		}
		e.EncodeString("ready")
		w.Write([]byte{'0', '0', '0', '1'})
	}

	// Return wanted-refs

	fmt.Printf("wanted: %v\n", wanted)

	// Open repository
	repo, err := git.PlainOpen(repoPath)
	if err != nil {
		fmt.Printf("err: %v\n", err)
		return caddyhttp.Error(http.StatusInternalServerError, fmt.Errorf("could not load repository"))
	}

	// Search commit for objects
	c, err := repo.CommitObject(objHashes[0])
	t, err := c.Tree()
	if err != nil {
		return caddyhttp.Error(http.StatusInternalServerError, err)
	}
	fmt.Printf("tree: %s\n", t.Hash.String())
	objHashes = append(objHashes, t.Hash)

	tw := object.NewTreeWalker(t, false, make(map[plumbing.Hash]bool))

	for {
		name, entry, err := tw.Next()
		if err == io.EOF {
			break
		} else if err != nil {
			return caddyhttp.Error(http.StatusInternalServerError, err)
		}
		fmt.Printf("object: %s %s\n", name, entry.Hash.String())
		objHashes = append(objHashes, entry.Hash)
	}

	// Return packfile

	e.EncodeString("packfile")

	// Create packfile encoder
	// pe := packfile.NewEncoder(w, repo.Storer, false)

	f, err := os.Create("test.pack")
	if err != nil {
		fmt.Printf("err: %v\n", err)
		caddyhttp.Error(http.StatusInternalServerError, err)
	}
	fe := packfile.NewEncoder(f, repo.Storer, false)

	// Write packfile with objects to writer
	// _, err = pe.Encode(objHashes, 0)
	// if err != nil {
	// 	fmt.Printf("err: %v\n", err)
	// 	caddyhttp.Error(http.StatusInternalServerError, err)
	// }
	_, err = fe.Encode(objHashes, 0)
	if err != nil {
		fmt.Printf("err: %v\n", err)
		caddyhttp.Error(http.StatusInternalServerError, err)
	}
	f.Close()

	packfile, err := os.ReadFile("test.pack")
	if err != nil {
		fmt.Printf("err: %v\n", err)
		caddyhttp.Error(http.StatusInternalServerError, err)
	}

	fmt.Fprintf(w, "%.4x", len(packfile)+5)
	w.Write([]byte{1})
	w.Write(packfile)

	e.Flush()

	return nil
}