drone/gitrpc/server/http.go

255 lines
7.3 KiB
Go

// Copyright 2023 Harness, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package server
import (
"bytes"
"compress/gzip"
"fmt"
"io"
"net/http"
"path/filepath"
"regexp"
"strconv"
"strings"
gitnesshttp "github.com/harness/gitness/http"
"code.gitea.io/gitea/modules/git"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"github.com/rs/zerolog/hlog"
"github.com/rs/zerolog/log"
)
const (
PathParamRepoUID = "repoUID"
)
var (
safeGitProtocolHeader = regexp.MustCompile(`^[0-9a-zA-Z]+=[0-9a-zA-Z]+(:[0-9a-zA-Z]+=[0-9a-zA-Z]+)*$`)
)
// HTTPServer exposes the gitrpc rest api.
type HTTPServer struct {
*gitnesshttp.Server
}
func NewHTTPServer(config Config) (*HTTPServer, error) {
if err := config.Validate(); err != nil {
return nil, fmt.Errorf("configuration is invalid: %w", err)
}
reposRoot := filepath.Join(config.GitRoot, repoSubdirName)
return &HTTPServer{
gitnesshttp.NewServer(
gitnesshttp.Config{
Port: config.HTTP.Port,
},
handleHTTP(reposRoot),
),
}, nil
}
func handleHTTP(reposRoot string) http.Handler {
r := chi.NewRouter()
// Apply common api middleware.
r.Use(middleware.NoCache)
r.Use(middleware.Recoverer)
// configure logging middleware.
log := log.Logger.With().Logger()
r.Use(hlog.NewHandler(log))
r.Use(hlog.URLHandler("http.url"))
r.Use(hlog.MethodHandler("http.method"))
r.Use(HLogRequestIDHandler())
r.Use(HLogAccessLogHandler())
r.Route(fmt.Sprintf("/{%s}", PathParamRepoUID), func(r chi.Router) {
r.Get("/info/refs", handleHTTPInfoRefs(reposRoot))
r.Handle("/git-upload-pack", handleHTTPUploadPack(reposRoot))
// push is not supported
r.Post("/git-receive-pack", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotImplemented)
_, _ = w.Write([]byte("receive pack is not supported by this endpoint"))
})
})
return r
}
func handleHTTPInfoRefs(reposRoot string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Clients MUST NOT reuse or revalidate a cached response.
// Servers MUST include sufficient Cache-Control headers to prevent caching of the response.
// https://git-scm.com/docs/http-protocol
setHeaderNoCache(w)
repoUID := chi.URLParam(r, PathParamRepoUID)
repoPath := getFullPathForRepo(reposRoot, repoUID)
gitProtocol := r.Header.Get("Git-Protocol")
service := getServiceType(r)
log.Ctx(ctx).Trace().Msgf(
"handleHTTPInfoRefs for git service: '%s', protocol: '%s', path: '%s'",
service,
gitProtocol,
repoPath,
)
w.Header().Set("Content-Type", fmt.Sprintf("application/x-git-%s-advertisement", service))
// NOTE: Don't include os.Environ() as we don't have control over it - define everything explicitly
environ := []string{}
if gitProtocol != "" {
environ = append(environ, "GIT_PROTOCOL="+gitProtocol)
}
stdOut := &bytes.Buffer{}
if err := git.NewCommand(ctx, service, "--stateless-rpc", "--advertise-refs", ".").
Run(&git.RunOpts{
Env: environ,
Dir: repoPath,
Stdout: stdOut,
}); err != nil {
w.WriteHeader(http.StatusInternalServerError)
log.Ctx(ctx).Error().Err(err).Msgf("failed running git command")
return
}
if _, err := w.Write(packetWrite("# service=git-" + service + "\n")); err != nil {
w.WriteHeader(http.StatusInternalServerError)
log.Ctx(ctx).Error().Err(err).Msgf("failed writing packet line")
return
}
if _, err := w.Write([]byte("0000")); err != nil {
w.WriteHeader(http.StatusInternalServerError)
log.Ctx(ctx).Error().Err(err).Msgf("failed writing end of response")
return
}
if _, err := io.Copy(w, stdOut); err != nil {
w.WriteHeader(http.StatusInternalServerError)
log.Ctx(ctx).Warn().Err(err).Msgf("failed copying response body")
return
}
}
}
func handleHTTPUploadPack(reposRoot string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
const service = "upload-pack"
repoUID := chi.URLParam(r, PathParamRepoUID)
repoPath := getFullPathForRepo(reposRoot, repoUID)
gitProtocol := r.Header.Get("Git-Protocol")
log.Ctx(ctx).Trace().Msgf(
"handleHTTPUploadPack for git service: '%s', protocol: '%s', path: '%s'",
service,
gitProtocol,
repoPath,
)
w.Header().Set("Content-Type", fmt.Sprintf("application/x-git-%s-result", service))
var err error
reqBody := r.Body
// Handle GZIP.
if r.Header.Get("Content-Encoding") == "gzip" {
reqBody, err = gzip.NewReader(reqBody)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
log.Ctx(ctx).Error().Err(err).Msgf("failed gziping response body")
return
}
}
// NOTE: Don't include os.Environ() as we don't have control over it - define everything explicitly
environ := []string{}
// set this for allow pre-receive and post-receive execute
environ = append(environ, "SSH_ORIGINAL_COMMAND="+service)
if gitProtocol != "" && safeGitProtocolHeader.MatchString(gitProtocol) {
environ = append(environ, "GIT_PROTOCOL="+gitProtocol)
}
var (
stderr bytes.Buffer
)
cmd := git.NewCommand(ctx, service, "--stateless-rpc", repoPath)
cmd.SetDescription(fmt.Sprintf("%s %s %s [repo_path: %s]", git.GitExecutable, service, "--stateless-rpc", repoPath))
err = cmd.Run(&git.RunOpts{
Dir: repoPath,
Env: environ,
Stdout: w,
Stdin: reqBody,
Stderr: &stderr,
UseContextTimeout: true,
})
if err != nil {
log.Ctx(ctx).Error().Err(err).Msgf("Failed to serve RPC(%s) in %s: %v - %s", service, repoPath, err, stderr.String())
w.WriteHeader(http.StatusInternalServerError)
}
}
}
func setHeaderNoCache(w http.ResponseWriter) {
w.Header().Set("Expires", "Fri, 01 Jan 1980 00:00:00 GMT")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
}
func getServiceType(r *http.Request) string {
serviceType := r.URL.Query().Get("service")
if !strings.HasPrefix(serviceType, "git-") {
return ""
}
return strings.Replace(serviceType, "git-", "", 1)
}
// getFullPathForRepo returns the full path of a repo given the root dir of repos and the uid of the repo.
// NOTE: Split repos into subfolders using their prefix to distribute repos across a set of folders.
// TODO: Use common function between grpc and git server
func getFullPathForRepo(reposRoot, uid string) string {
// ASSUMPTION: repoUID is of lenth at least 4 - otherwise we have trouble either way.
return filepath.Join(
reposRoot, // root folder
uid[0:2], // first subfolder
uid[2:4], // second subfolder
fmt.Sprintf("%s.%s", uid[4:], "git"), // remainder with .git
)
}
func packetWrite(str string) []byte {
s := strconv.FormatInt(int64(len(str)+4), 16)
if len(s)%4 != 0 {
s = strings.Repeat("0", 4-len(s)%4) + s
}
return []byte(s + str)
}