prime8dev

prime8dev
git clone https://git.prime8.dev/prime8dev.git
Log | Files | Refs | README | LICENSE

git.go (4299B)


      1 package ssh
      2 
      3 import (
      4 	"errors"
      5 	"os"
      6 	"os/exec"
      7 	"path/filepath"
      8 	"regexp"
      9 	"strings"
     10 
     11 	charmssh "github.com/charmbracelet/ssh"
     12 )
     13 
     14 const defaultGitReposDir = "/srv/git/repos"
     15 
     16 var repoNameRegex = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
     17 
     18 // gitReposDir is where the bare repositories live (env GIT_REPOS_DIR).
     19 func gitReposDir() string {
     20 	if dir := strings.TrimSpace(os.Getenv("GIT_REPOS_DIR")); dir != "" {
     21 		return dir
     22 	}
     23 	return defaultGitReposDir
     24 }
     25 
     26 // repoName maps the path a git client asks for ("janitor.git", "/janitor",
     27 // "~/janitor.git") to a bare repo name.
     28 func repoName(requested string) (string, bool) {
     29 	name := strings.TrimPrefix(strings.TrimSpace(requested), "~")
     30 	name = strings.Trim(name, "/")
     31 	name = strings.TrimSuffix(name, ".git")
     32 	if !repoNameRegex.MatchString(name) || strings.Contains(name, "..") {
     33 		return "", false
     34 	}
     35 	return name, true
     36 }
     37 
     38 // publicRepoDir maps a requested path to a public bare repo directory. Only
     39 // repos marked with git-daemon-export-ok are served to guests.
     40 func publicRepoDir(requested string) (string, bool) {
     41 	name, ok := repoName(requested)
     42 	if !ok {
     43 		return "", false
     44 	}
     45 	dir := filepath.Join(gitReposDir(), name+".git")
     46 	if _, err := os.Stat(filepath.Join(dir, "git-daemon-export-ok")); err != nil {
     47 		return "", false
     48 	}
     49 	return dir, true
     50 }
     51 
     52 // ownerRepoDir maps a requested path to any existing bare repo, private
     53 // ones included.
     54 func ownerRepoDir(requested string) (string, bool) {
     55 	name, ok := repoName(requested)
     56 	if !ok {
     57 		return "", false
     58 	}
     59 	dir := filepath.Join(gitReposDir(), name+".git")
     60 	if _, err := os.Stat(filepath.Join(dir, "HEAD")); err != nil {
     61 		return "", false
     62 	}
     63 	return dir, true
     64 }
     65 
     66 // gitService returns the git service a session asks for, or "".
     67 func gitService(sess charmssh.Session) string {
     68 	cmd := sess.Command()
     69 	if len(cmd) != 2 {
     70 		return ""
     71 	}
     72 	switch cmd[0] {
     73 	case "git-upload-pack", "git-receive-pack":
     74 		return cmd[0]
     75 	}
     76 	return ""
     77 }
     78 
     79 // serveGit runs a git service for the session and returns its exit code.
     80 // Guests may only fetch public repos. The owner may fetch any repo and push,
     81 // and a push to a new name creates it (private until marked export-ok).
     82 func serveGit(sess charmssh.Session, service string, owner bool) int {
     83 	requested := sess.Command()[1]
     84 	var (
     85 		dir string
     86 		ok  bool
     87 	)
     88 	switch {
     89 	case service == "git-upload-pack" && owner:
     90 		dir, ok = ownerRepoDir(requested)
     91 	case service == "git-upload-pack":
     92 		dir, ok = publicRepoDir(requested)
     93 	case owner:
     94 		dir, ok = ownerRepoDir(requested)
     95 		if !ok {
     96 			dir, ok = createRepo(sess, requested)
     97 		}
     98 	default:
     99 		_, _ = sess.Stderr().Write([]byte("fatal: push needs the owner's key\n"))
    100 		return 128
    101 	}
    102 	if !ok {
    103 		_, _ = sess.Stderr().Write([]byte("fatal: repository not found\n"))
    104 		return 128
    105 	}
    106 
    107 	var cmd *exec.Cmd
    108 	if service == "git-upload-pack" {
    109 		cmd = exec.CommandContext(sess.Context(), "git-upload-pack", "--strict", "--timeout=60", dir)
    110 	} else {
    111 		cmd = exec.CommandContext(sess.Context(), "git", "-c", "core.hooksPath="+gitHooksDir(), "receive-pack", dir)
    112 	}
    113 	cmd.Env = append(os.Environ(), "GIT_PROTOCOL="+sessionEnv(sess, "GIT_PROTOCOL"))
    114 	cmd.Stdin = sess
    115 	cmd.Stdout = sess
    116 	cmd.Stderr = sess.Stderr()
    117 
    118 	if err := cmd.Run(); err != nil {
    119 		var exitErr *exec.ExitError
    120 		if errors.As(err, &exitErr) {
    121 			return exitErr.ExitCode()
    122 		}
    123 		return 128
    124 	}
    125 	return 0
    126 }
    127 
    128 // gitHooksDir holds the hooks every push runs (env GIT_HOOKS_DIR). The repos'
    129 // own hooks are host scripts that don't exist in the container.
    130 func gitHooksDir() string {
    131 	if dir := strings.TrimSpace(os.Getenv("GIT_HOOKS_DIR")); dir != "" {
    132 		return dir
    133 	}
    134 	return "/app/git-hooks"
    135 }
    136 
    137 func createRepo(sess charmssh.Session, requested string) (string, bool) {
    138 	name, ok := repoName(requested)
    139 	if !ok {
    140 		return "", false
    141 	}
    142 	dir := filepath.Join(gitReposDir(), name+".git")
    143 	cmd := exec.CommandContext(sess.Context(), "git", "init", "-q", "--bare", "-b", "master", dir)
    144 	cmd.Stderr = sess.Stderr()
    145 	if err := cmd.Run(); err != nil {
    146 		return "", false
    147 	}
    148 	_, _ = sess.Stderr().Write([]byte("created private repo " + name + ".git\n"))
    149 	return dir, true
    150 }
    151 
    152 func sessionEnv(sess charmssh.Session, key string) string {
    153 	for _, kv := range sess.Environ() {
    154 		if k, v, ok := strings.Cut(kv, "="); ok && k == key {
    155 			return v
    156 		}
    157 	}
    158 	return ""
    159 }