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 }