├── .github └── workflows │ └── build.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── config.go ├── credential.go ├── credential_test.go ├── git_command.go ├── git_command_test.go ├── go.mod ├── go.sum ├── hook_info.go ├── hook_info_test.go ├── http.go ├── receiver.go ├── ssh.go ├── utils.go ├── utils_test.go ├── version.go └── write_flusher.go /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | - push 5 | 6 | env: 7 | GO_VERSION: 1.18 8 | 9 | jobs: 10 | build: 11 | name: tests 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | 16 | - name: Checkout code 17 | uses: actions/checkout@v2 18 | with: 19 | fetch-depth: 0 20 | 21 | - name: Setup Go 22 | uses: actions/setup-go@v2 23 | with: 24 | go-version: ${{ env.GO_VERSION }} 25 | 26 | - name: Install packages 27 | run: go mod download 28 | 29 | - name: Execute tests 30 | run: make test 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | bin 3 | tmp/ 4 | cover.out -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-2026 Dan Sosedoff 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | go test -v -race -cover . 3 | 4 | build: 5 | go build 6 | 7 | lint: 8 | golangci-lint run 9 | 10 | all: 11 | gox -osarch="darwin/amd64 linux/amd64" -output="gitkit_{{.OS}}_{{.Arch}}" 12 | 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gitkit 2 | 3 | Toolkit to build Git push workflows with Go 4 | 5 | [![Build](https://github.com/sosedoff/gitkit/actions/workflows/build.yml/badge.svg)](https://github.com/sosedoff/gitkit/actions/workflows/build.yml) 6 | [![GoDoc](https://godoc.org/github.com/sosedoff/gitkit?status.svg)](https://godoc.org/github.com/sosedoff/gitkit) 7 | 8 | ## Install 9 | 10 | ```bash 11 | go get github.com/sosedoff/gitkit 12 | ``` 13 | 14 | ## Smart HTTP Server 15 | 16 | ```go 17 | package main 18 | 19 | import ( 20 | "log" 21 | "net/http" 22 | "github.com/sosedoff/gitkit" 23 | ) 24 | 25 | func main() { 26 | // Configure git hooks 27 | hooks := &gitkit.HookScripts{ 28 | PreReceive: `echo "Hello World!"`, 29 | } 30 | 31 | // Configure git service 32 | service := gitkit.New(gitkit.Config{ 33 | Dir: "/path/to/repos", 34 | AutoCreate: true, 35 | AutoHooks: true, 36 | Hooks: hooks, 37 | }) 38 | 39 | // Configure git server. Will create git repos path if it does not exist. 40 | // If hooks are set, it will also update all repos with new version of hook scripts. 41 | if err := service.Setup(); err != nil { 42 | log.Fatal(err) 43 | } 44 | 45 | http.Handle("/", service) 46 | 47 | // Start HTTP server 48 | if err := http.ListenAndServe(":5000", nil); err != nil { 49 | log.Fatal(err) 50 | } 51 | } 52 | ``` 53 | 54 | Run example: 55 | 56 | ```bash 57 | go run example.go 58 | ``` 59 | 60 | Then try to clone a test repository: 61 | 62 | ```bash 63 | $ git clone http://localhost:5000/test.git /tmp/test 64 | # Cloning into '/tmp/test'... 65 | # warning: You appear to have cloned an empty repository. 66 | # Checking connectivity... done. 67 | 68 | $ cd /tmp/test 69 | $ touch sample 70 | 71 | $ git add sample 72 | $ git commit -am "First commit" 73 | # [master (root-commit) fe40c98] First commit 74 | # 1 file changed, 0 insertions(+), 0 deletions(-) 75 | # create mode 100644 sample 76 | 77 | $ git push origin master 78 | # Counting objects: 3, done. 79 | # Writing objects: 100% (3/3), 213 bytes | 0 bytes/s, done. 80 | # Total 3 (delta 0), reused 0 (delta 0) 81 | # remote: Hello World! <----------------- pre-receive hook 82 | # To http://localhost:5000/test.git 83 | # * [new branch] master -> master 84 | ``` 85 | 86 | In the example's console you'll see something like this: 87 | 88 | ```bash 89 | 2016/05/20 20:01:42 request: GET localhost:5000/test.git/info/refs?service=git-upload-pack 90 | 2016/05/20 20:01:42 repo-init: creating pre-receive hook for test.git 91 | 2016/05/20 20:03:34 request: GET localhost:5000/test.git/info/refs?service=git-receive-pack 92 | 2016/05/20 20:03:34 request: POST localhost:5000/test.git/git-receive-pack 93 | ``` 94 | 95 | ### Authentication 96 | 97 | ```go 98 | package main 99 | 100 | import ( 101 | "log" 102 | "net/http" 103 | 104 | "github.com/sosedoff/gitkit" 105 | ) 106 | 107 | func main() { 108 | service := gitkit.New(gitkit.Config{ 109 | Dir: "/path/to/repos", 110 | AutoCreate: true, 111 | Auth: true, // Turned off by default 112 | }) 113 | 114 | // Here's the user-defined authentication function. 115 | // If return value is false or error is set, user's request will be rejected. 116 | // You can hook up your database/redis/cache for authentication purposes. 117 | service.AuthFunc = func(cred gitkit.Credential, req *gitkit.Request) (bool, error) { 118 | log.Println("user auth request for repo:", cred.Username, cred.Password, req.RepoName) 119 | return cred.Username == "hello", nil 120 | } 121 | 122 | http.Handle("/", service) 123 | http.ListenAndServe(":5000", nil) 124 | } 125 | ``` 126 | 127 | When you start the server and try to clone repo, you'll see password prompt. Two 128 | examples below illustrate both failed and succesful authentication based on the 129 | auth code above. 130 | 131 | ```bash 132 | $ git clone http://localhost:5000/awesome-sauce.git 133 | # Cloning into 'awesome-sauce'... 134 | # Username for 'http://localhost:5000': foo 135 | # Password for 'http://foo@localhost:5000': 136 | # fatal: Authentication failed for 'http://localhost:5000/awesome-sauce.git/' 137 | 138 | $ git clone http://localhost:5000/awesome-sauce.git 139 | # Cloning into 'awesome-sauce'... 140 | # Username for 'http://localhost:5000': hello 141 | # Password for 'http://hello@localhost:5000': 142 | # warning: You appear to have cloned an empty repository. 143 | # Checking connectivity... done. 144 | ``` 145 | 146 | Git also allows using `.netrc` files for authentication purposes. Open your `~/.netrc` 147 | file and add the following line: 148 | 149 | ``` 150 | machine localhost 151 | login hello 152 | password world 153 | ``` 154 | 155 | Next time you try clone the same localhost git repo, git wont show password promt. 156 | Keep in mind that the best practice is to use auth tokens instead of plaintext passwords 157 | for authentication. See [Heroku's docs](https://devcenter.heroku.com/articles/authentication#api-token-storage) 158 | for more information. 159 | 160 | ## SSH server 161 | 162 | ```go 163 | package main 164 | 165 | import ( 166 | "log" 167 | "github.com/sosedoff/gitkit" 168 | ) 169 | 170 | // User-defined key lookup function. You can make a call to a database or 171 | // some sort of cache storage (redis/memcached) to speed things up. 172 | // Content is a string containing ssh public key of a user. 173 | func lookupKey(content string) (*gitkit.PublicKey, error) { 174 | return &gitkit.PublicKey{Id: "12345"}, nil 175 | } 176 | 177 | func main() { 178 | // In the example below you need to specify a full path to a directory that 179 | // contains all git repositories, and also a directory that has a gitkit specific 180 | // ssh private and public key pair that used to run ssh server. 181 | server := gitkit.NewSSH(gitkit.Config{ 182 | Dir: "/path/to/git/repos", 183 | KeyDir: "/path/to/gitkit", 184 | }) 185 | 186 | // User-defined key lookup function. All requests will be rejected if this function 187 | // is not provider. SSH server only accepts key-based authentication. 188 | server.PublicKeyLookupFunc = lookupKey 189 | 190 | // Specify host and port to run the server on. 191 | err := server.ListenAndServe(":2222") 192 | if err != nil { 193 | log.Fatal(err) 194 | } 195 | } 196 | ``` 197 | 198 | Example above uses non-standard SSH port 2222, which can't be used for local testing 199 | by default. To make it work you must modify you ssh client configuration file with 200 | the following snippet: 201 | 202 | ``` 203 | $ nano ~/.ssh/config 204 | ``` 205 | 206 | Paste the following: 207 | 208 | ``` 209 | Host localhost 210 | Port 2222 211 | ``` 212 | 213 | Now that the server is configured, we can fire it up: 214 | 215 | ```bash 216 | $ go run ssh_server.go 217 | ``` 218 | 219 | First thing you'll need to make sure you have tested the ssh host verification: 220 | 221 | ```bash 222 | $ ssh git@localhost -p 2222 223 | # The authenticity of host '[localhost]:2222 ([::1]:2222)' can't be established. 224 | # RSA key fingerprint is SHA256:eZwC9VSbVnoHFRY9QKGK3aBSUqkShRF0HxFmQyLmBJs. 225 | # Are you sure you want to continue connecting (yes/no)? yes 226 | # Warning: Permanently added '[localhost]:2222' (RSA) to the list of known hosts. 227 | # Unsupported request type. 228 | # Connection to localhost closed. 229 | ``` 230 | 231 | All good now. `Unsupported request type.` is a succes output since gitkit does not 232 | allow running shell sessions. Assuming you have configured the directory for git 233 | repositories, clone the test repo: 234 | 235 | ```bash 236 | $ git clone git@localhost:test.git 237 | # Cloning into 'test'... 238 | # remote: Counting objects: 3, done. 239 | # remote: Total 3 (delta 0), reused 0 (delta 0) 240 | # Receiving objects: 100% (3/3), done. 241 | # Checking connectivity... done. 242 | ``` 243 | 244 | Done, you have now ability to run git push/pull. The important stuff in all examples 245 | above is `lookupKey` function. It controls whether user is allowd to authenticate with 246 | ssh or not. 247 | 248 | ## Receiver 249 | 250 | In Git, The first script to run when handling a push from a client is pre-receive. 251 | It takes a list of references that are being pushed from stdin; if it exits non-zero, 252 | none of them are accepted. [More on hooks](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks). 253 | 254 | ```go 255 | package main 256 | 257 | import ( 258 | "log" 259 | "os" 260 | "fmt" 261 | 262 | "github.com/sosedoff/gitkit" 263 | ) 264 | 265 | // HookInfo contains information about branch, before and after revisions. 266 | // tmpPath is a temporary directory with checked out git tree for the commit. 267 | func receive(hook *gitkit.HookInfo, tmpPath string) error { 268 | log.Println("Action:", hook.Action) 269 | log.Println("Ref:", hook.Ref) 270 | log.Println("Ref name:", hook.RefName) 271 | log.Println("Old revision:", hook.OldRev) 272 | log.Println("New revision:", hook.NewRev) 273 | 274 | // Check if push is non fast-forward (force) 275 | force, err := gitkit.IsForcePush(hook) 276 | if err != nil { 277 | return err 278 | } 279 | 280 | // Reject force push 281 | if force { 282 | return fmt.Errorf("non fast-forward pushed are not allowed") 283 | } 284 | 285 | // Check if branch is being deleted 286 | if hook.Action == gitkit.BranchDeleteAction { 287 | fmt.Println("Deleting branch!") 288 | return nil 289 | } 290 | 291 | // Getting a commit message is built-in 292 | message, err := gitkit.ReadCommitMessage(hook.NewRev) 293 | if err != nil { 294 | return err 295 | } 296 | log.Println("Commit message:", message) 297 | 298 | return nil 299 | } 300 | 301 | func main() { 302 | receiver := gitkit.Receiver{ 303 | MasterOnly: false, // if set to true, only pushes to master branch will be allowed 304 | TmpDir: "/tmp/gitkit", // directory for temporary git checkouts 305 | HandlerFunc: receive, // your handler function 306 | } 307 | 308 | // Git hook data is provided via STDIN 309 | if err := receiver.Handle(os.Stdin); err != nil { 310 | log.Println("Error:", err) 311 | os.Exit(1) // terminating with non-zero status will cancel push 312 | } 313 | } 314 | ``` 315 | 316 | To test if receiver works, you will need to add a sample pre-receive hook to any 317 | git repo. With `go run` its easier to debug but final script should be compiled 318 | and will run very fast. 319 | 320 | ```bash 321 | #!/bin/bash 322 | cat | go run /path/to/your-receiver.go 323 | ``` 324 | 325 | Modify something in the repo, commit the change and push: 326 | 327 | ```bash 328 | $ git push 329 | # Counting objects: 3, done. 330 | # Delta compression using up to 8 threads. 331 | # Compressing objects: 100% (3/3), done. 332 | # Writing objects: 100% (3/3), 286 bytes | 0 bytes/s, done. 333 | # Total 3 (delta 2), reused 0 (delta 0) 334 | # -------------------------- out receiver output is here ---------------- 335 | # remote: 2016/05/24 17:21:37 Ref: refs/heads/master 336 | # remote: 2016/05/24 17:21:37 Old revision: 5ee8d0891d1e5574e427dc16e0908cb9d28551b9 337 | # remote: 2016/05/24 17:21:37 New revision: e13d6b3a27403029fe674e7b911efd468b035a33 338 | # remote: 2016/05/24 17:21:37 Message: Remove stuff 339 | # To git@localhost:dummy-app.git 340 | # 5ee8d08..e13d6b3 master -> master 341 | ``` 342 | 343 | ## Extras 344 | 345 | ### Remove remote: prefix 346 | 347 | If your pre-receive script logs anything to STDOUT, the output might look 348 | like this: 349 | 350 | ```bash 351 | # Writing objects: 100% (3/3), 286 bytes | 0 bytes/s, done. 352 | # Total 3 (delta 2), reused 0 (delta 0) 353 | remote: Sample script output <---- YOUR SCRIPT 354 | ``` 355 | 356 | There's a simple hack to remove this nasty `remote:` prefix: 357 | 358 | ```bash 359 | #!/bin/bash 360 | /my/receiver-script | sed -u "s/^/"$'\e[1G\e[K'"/" 361 | ``` 362 | 363 | If you're running on OSX, use `gsed` instead: `brew install gnu-sed`. 364 | 365 | Result: 366 | 367 | ```bash 368 | # Writing objects: 100% (3/3), 286 bytes | 0 bytes/s, done. 369 | # Total 3 (delta 2), reused 0 (delta 0) 370 | Sample script output 371 | ``` 372 | 373 | ## References 374 | 375 | - https://git-scm.com/book/en/v2/Git-Internals-Transfer-Protocols 376 | 377 | ## License 378 | 379 | The MIT License 380 | 381 | Copyright (c) 2016-2023 Dan Sosedoff, 382 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package gitkit 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | type Config struct { 10 | KeyDir string // Directory for server ssh keys. Only used in SSH strategy. 11 | Dir string // Directory that contains repositories 12 | GitPath string // Path to git binary 13 | GitUser string // User for ssh connections 14 | AutoCreate bool // Automatically create repostories 15 | AutoHooks bool // Automatically setup git hooks 16 | Hooks *HookScripts // Scripts for hooks/* directory 17 | Auth bool // Require authentication 18 | } 19 | 20 | // HookScripts represents all repository server-size git hooks 21 | type HookScripts struct { 22 | PreReceive string 23 | Update string 24 | PostReceive string 25 | } 26 | 27 | // Configure hook scripts in the repo base directory 28 | func (c *HookScripts) setupInDir(path string) error { 29 | basePath := filepath.Join(path, "hooks") 30 | scripts := map[string]string{ 31 | "pre-receive": c.PreReceive, 32 | "update": c.Update, 33 | "post-receive": c.PostReceive, 34 | } 35 | 36 | // Cleanup any existing hooks first 37 | hookFiles, err := ioutil.ReadDir(basePath) 38 | if err == nil { 39 | for _, file := range hookFiles { 40 | if err := os.Remove(filepath.Join(basePath, file.Name())); err != nil { 41 | return err 42 | } 43 | } 44 | } 45 | 46 | // Write new hook files 47 | for name, script := range scripts { 48 | fullPath := filepath.Join(basePath, name) 49 | 50 | // Dont create hook if there's no script content 51 | if script == "" { 52 | continue 53 | } 54 | 55 | if err := ioutil.WriteFile(fullPath, []byte(script), 0755); err != nil { 56 | logError("hook-update", err) 57 | return err 58 | } 59 | } 60 | 61 | return nil 62 | } 63 | 64 | func (c *Config) KeyPath() string { 65 | return filepath.Join(c.KeyDir, "gitkit.rsa") 66 | } 67 | 68 | func (c *Config) Setup() error { 69 | if _, err := os.Stat(c.Dir); err != nil { 70 | if err = os.Mkdir(c.Dir, 0755); err != nil { 71 | return err 72 | } 73 | } 74 | 75 | if c.AutoHooks && c.Hooks != nil { 76 | return c.setupHooks() 77 | } 78 | 79 | return nil 80 | } 81 | 82 | func (c *Config) setupHooks() error { 83 | files, err := ioutil.ReadDir(c.Dir) 84 | if err != nil { 85 | return err 86 | } 87 | 88 | for _, file := range files { 89 | if !file.IsDir() { 90 | continue 91 | } 92 | 93 | path := filepath.Join(c.Dir, file.Name()) 94 | 95 | if err := c.Hooks.setupInDir(path); err != nil { 96 | return err 97 | } 98 | } 99 | 100 | return nil 101 | } 102 | -------------------------------------------------------------------------------- /credential.go: -------------------------------------------------------------------------------- 1 | package gitkit 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | type Credential struct { 9 | Username string 10 | Password string 11 | } 12 | 13 | func getCredential(req *http.Request) (Credential, error) { 14 | cred := Credential{} 15 | 16 | user, pass, ok := req.BasicAuth() 17 | if !ok { 18 | return cred, fmt.Errorf("authentication failed") 19 | } 20 | 21 | cred.Username = user 22 | cred.Password = pass 23 | 24 | return cred, nil 25 | } 26 | -------------------------------------------------------------------------------- /credential_test.go: -------------------------------------------------------------------------------- 1 | package gitkit 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func Test_getCredential(t *testing.T) { 11 | req, _ := http.NewRequest("get", "http://localhost", nil) 12 | _, err := getCredential(req) 13 | assert.Error(t, err) 14 | assert.Equal(t, "authentication failed", err.Error()) 15 | 16 | req, _ = http.NewRequest("get", "http://localhost", nil) 17 | req.SetBasicAuth("Alladin", "OpenSesame") 18 | cred, err := getCredential(req) 19 | 20 | assert.NoError(t, err) 21 | assert.Equal(t, "Alladin", cred.Username) 22 | assert.Equal(t, "OpenSesame", cred.Password) 23 | } 24 | -------------------------------------------------------------------------------- /git_command.go: -------------------------------------------------------------------------------- 1 | package gitkit 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | ) 8 | 9 | var gitCommandRegex = regexp.MustCompile(`^(git[-|\s]upload-pack|git[-|\s]upload-archive|git[-|\s]receive-pack) '(.*)'$`) 10 | 11 | type GitCommand struct { 12 | Command string 13 | Repo string 14 | Original string 15 | } 16 | 17 | func ParseGitCommand(cmd string) (*GitCommand, error) { 18 | matches := gitCommandRegex.FindAllStringSubmatch(cmd, 1) 19 | if len(matches) == 0 { 20 | return nil, fmt.Errorf("invalid git command") 21 | } 22 | 23 | result := &GitCommand{ 24 | Original: cmd, 25 | Command: matches[0][1], 26 | Repo: strings.Replace(matches[0][2], "/", "", 1), 27 | } 28 | 29 | return result, nil 30 | } 31 | -------------------------------------------------------------------------------- /git_command_test.go: -------------------------------------------------------------------------------- 1 | package gitkit 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestParseGitCommand(t *testing.T) { 10 | examples := map[string]GitCommand{ 11 | "git-upload-pack 'hello.git'": {"git-upload-pack", "hello.git", "git-upload-pack 'hello.git'"}, 12 | "git upload-pack 'hello.git'": {"git upload-pack", "hello.git", "git upload-pack 'hello.git'"}, 13 | "git-upload-pack '/hello.git'": {"git-upload-pack", "hello.git", "git-upload-pack 'hello.git'"}, 14 | "git-upload-pack '/hello/world.git'": {"git-upload-pack", "hello/world.git", "git-upload-pack 'hello.git'"}, 15 | "git-receive-pack 'hello.git'": {"git-receive-pack", "hello.git", "git-receive-pack 'hello.git'"}, 16 | "git receive-pack 'hello.git'": {"git receive-pack", "hello.git", "git receive-pack 'hello.git'"}, 17 | "git-upload-archive 'hello.git'": {"git-upload-archive", "hello.git", "git-upload-archive 'hello.git'"}, 18 | "git upload-archive 'hello.git'": {"git upload-archive", "hello.git", "git upload-archive 'hello.git'"}, 19 | } 20 | 21 | for s, expected := range examples { 22 | cmd, err := ParseGitCommand(s) 23 | 24 | assert.NoError(t, err) 25 | assert.Equal(t, expected.Command, cmd.Command) 26 | assert.Equal(t, expected.Repo, cmd.Repo) 27 | } 28 | 29 | cmd, err := ParseGitCommand("git do-stuff") 30 | assert.Error(t, err) 31 | assert.Nil(t, cmd) 32 | } 33 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/sosedoff/gitkit 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/gofrs/uuid v4.4.0+incompatible 7 | github.com/stretchr/testify v1.7.0 8 | golang.org/x/crypto v0.11.0 9 | ) 10 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= 4 | github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= 5 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 6 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 7 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 8 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 9 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 10 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 11 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 12 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 13 | golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA= 14 | golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= 15 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 16 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 17 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 18 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 19 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 20 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 21 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 22 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 23 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 24 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 25 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 26 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 27 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 28 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 29 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 30 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 31 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 32 | golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= 33 | golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 34 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 35 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 36 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 37 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 38 | golang.org/x/term v0.10.0 h1:3R7pNqamzBraeqj/Tj8qt1aQ2HpmlC+Cx/qL/7hn4/c= 39 | golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= 40 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 41 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 42 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 43 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 44 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 45 | golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 46 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 47 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 48 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 49 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 50 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 51 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 52 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 53 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 54 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 55 | -------------------------------------------------------------------------------- /hook_info.go: -------------------------------------------------------------------------------- 1 | package gitkit 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | ) 11 | 12 | const ( 13 | BranchPushAction = "branch.push" 14 | BranchCreateAction = "branch.create" 15 | BranchDeleteAction = "branch.delete" 16 | TagCreateAction = "tag.create" 17 | TagDeleteAction = "tag.delete" 18 | ) 19 | 20 | // HookInfo holds git hook context 21 | type HookInfo struct { 22 | Action string 23 | RepoName string 24 | RepoPath string 25 | OldRev string 26 | NewRev string 27 | Ref string 28 | RefType string 29 | RefName string 30 | } 31 | 32 | // ReadHookInput reads the hook context 33 | func ReadHookInput(input io.Reader) (*HookInfo, error) { 34 | reader := bufio.NewReader(input) 35 | 36 | line, _, err := reader.ReadLine() 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | chunks := strings.Split(string(line), " ") 42 | if len(chunks) != 3 { 43 | return nil, fmt.Errorf("Invalid hook input") 44 | } 45 | refchunks := strings.Split(chunks[2], "/") 46 | 47 | dir, _ := os.Getwd() 48 | info := HookInfo{ 49 | RepoName: filepath.Base(dir), 50 | RepoPath: dir, 51 | OldRev: chunks[0], 52 | NewRev: chunks[1], 53 | Ref: chunks[2], 54 | RefType: refchunks[1], 55 | RefName: refchunks[2], 56 | } 57 | info.Action = parseHookAction(info) 58 | 59 | return &info, nil 60 | } 61 | 62 | func parseHookAction(h HookInfo) string { 63 | action := "push" 64 | context := "branch" 65 | 66 | if h.RefType == "tags" { 67 | context = "tag" 68 | } 69 | 70 | if h.OldRev == ZeroSHA && h.NewRev != ZeroSHA { 71 | action = "create" 72 | } else if h.OldRev != ZeroSHA && h.NewRev == ZeroSHA { 73 | action = "delete" 74 | } 75 | 76 | return fmt.Sprintf("%s.%s", context, action) 77 | } 78 | -------------------------------------------------------------------------------- /hook_info_test.go: -------------------------------------------------------------------------------- 1 | package gitkit 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestReadHookInput(t *testing.T) { 11 | input := "e285100b636ac67fa28d85685072158edaa01685 a3d33576d686e7dc1d90ec4b1a6e94e760a893b2 refs/heads/master\n" 12 | info, err := ReadHookInput(strings.NewReader(input)) 13 | 14 | assert.NoError(t, err) 15 | assert.Equal(t, "e285100b636ac67fa28d85685072158edaa01685", info.OldRev) 16 | assert.Equal(t, "a3d33576d686e7dc1d90ec4b1a6e94e760a893b2", info.NewRev) 17 | assert.Equal(t, "refs/heads/master", info.Ref) 18 | assert.Equal(t, "heads", info.RefType) 19 | assert.Equal(t, "master", info.RefName) 20 | } 21 | 22 | func TestHookAction(t *testing.T) { 23 | examples := map[string]HookInfo{ 24 | "branch.create": { 25 | OldRev: "0000000000000000000000000000000000000000", 26 | NewRev: "e285100b636ac67fa28d85685072158edaa01685", 27 | RefType: "heads", 28 | }, 29 | "branch.delete": { 30 | OldRev: "e285100b636ac67fa28d85685072158edaa01685", 31 | NewRev: "0000000000000000000000000000000000000000", 32 | RefType: "heads", 33 | }, 34 | "branch.push": { 35 | OldRev: "e285100b636ac67fa28d85685072158edaa01685", 36 | NewRev: "a3d33576d686e7dc1d90ec4b1a6e94e760a893b2", 37 | RefType: "heads", 38 | }, 39 | "tag.create": { 40 | OldRev: "0000000000000000000000000000000000000000", 41 | NewRev: "e285100b636ac67fa28d85685072158edaa01685", 42 | RefType: "tags", 43 | }, 44 | "tag.delete": { 45 | OldRev: "e285100b636ac67fa28d85685072158edaa01685", 46 | NewRev: "0000000000000000000000000000000000000000", 47 | RefType: "tags", 48 | }, 49 | } 50 | 51 | for expected, hook := range examples { 52 | assert.Equal(t, expected, parseHookAction(hook)) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /http.go: -------------------------------------------------------------------------------- 1 | package gitkit 2 | 3 | import ( 4 | "compress/gzip" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "os" 9 | "os/exec" 10 | "path" 11 | "strings" 12 | "syscall" 13 | ) 14 | 15 | type service struct { 16 | method string 17 | suffix string 18 | handler func(string, http.ResponseWriter, *Request) 19 | rpc string 20 | } 21 | 22 | type Server struct { 23 | config Config 24 | services []service 25 | AuthFunc func(Credential, *Request) (bool, error) 26 | } 27 | 28 | type Request struct { 29 | *http.Request 30 | RepoName string 31 | RepoPath string 32 | } 33 | 34 | func New(cfg Config) *Server { 35 | s := Server{config: cfg} 36 | s.services = []service{ 37 | service{"GET", "/info/refs", s.getInfoRefs, ""}, 38 | service{"POST", "/git-upload-pack", s.postRPC, "git-upload-pack"}, 39 | service{"POST", "/git-receive-pack", s.postRPC, "git-receive-pack"}, 40 | } 41 | 42 | // Use PATH if full path is not specified 43 | if s.config.GitPath == "" { 44 | s.config.GitPath = "git" 45 | } 46 | 47 | return &s 48 | } 49 | 50 | // findService returns a matching git subservice and parsed repository name 51 | func (s *Server) findService(req *http.Request) (*service, string) { 52 | for _, svc := range s.services { 53 | if svc.method == req.Method && strings.HasSuffix(req.URL.Path, svc.suffix) { 54 | path := strings.Replace(req.URL.Path, svc.suffix, "", 1) 55 | return &svc, path 56 | } 57 | } 58 | return nil, "" 59 | } 60 | 61 | func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { 62 | logInfo("request", r.Method+" "+r.Host+r.URL.String()) 63 | 64 | // Find the git subservice to handle the request 65 | svc, repoUrlPath := s.findService(r) 66 | if svc == nil { 67 | http.Error(w, "Forbidden", http.StatusForbidden) 68 | return 69 | } 70 | 71 | // Determine namespace and repo name from request path 72 | repoNamespace, repoName := getNamespaceAndRepo(repoUrlPath) 73 | if repoName == "" { 74 | logError("auth", fmt.Errorf("no repo name provided")) 75 | w.WriteHeader(http.StatusBadRequest) 76 | return 77 | } 78 | 79 | req := &Request{ 80 | Request: r, 81 | RepoName: path.Join(repoNamespace, repoName), 82 | RepoPath: path.Join(s.config.Dir, repoNamespace, repoName), 83 | } 84 | 85 | if s.config.Auth { 86 | if s.AuthFunc == nil { 87 | logError("auth", fmt.Errorf("no auth backend provided")) 88 | w.WriteHeader(http.StatusUnauthorized) 89 | return 90 | } 91 | 92 | authHeader := r.Header.Get("Authorization") 93 | if authHeader == "" { 94 | w.Header()["WWW-Authenticate"] = []string{`Basic realm=""`} 95 | w.WriteHeader(http.StatusUnauthorized) 96 | return 97 | } 98 | 99 | cred, err := getCredential(r) 100 | if err != nil { 101 | logError("auth", err) 102 | w.WriteHeader(http.StatusUnauthorized) 103 | return 104 | } 105 | 106 | allow, err := s.AuthFunc(cred, req) 107 | if !allow || err != nil { 108 | if err != nil { 109 | logError("auth", err) 110 | } 111 | 112 | logError("auth", fmt.Errorf("rejected user %s", cred.Username)) 113 | w.WriteHeader(http.StatusUnauthorized) 114 | return 115 | } 116 | } 117 | 118 | if !repoExists(req.RepoPath) && s.config.AutoCreate == true { 119 | err := initRepo(req.RepoName, &s.config) 120 | if err != nil { 121 | logError("repo-init", err) 122 | } 123 | } 124 | 125 | if !repoExists(req.RepoPath) { 126 | logError("repo-init", fmt.Errorf("%s does not exist", req.RepoPath)) 127 | http.NotFound(w, r) 128 | return 129 | } 130 | 131 | svc.handler(svc.rpc, w, req) 132 | } 133 | 134 | func (s *Server) getInfoRefs(_ string, w http.ResponseWriter, r *Request) { 135 | context := "get-info-refs" 136 | rpc := r.URL.Query().Get("service") 137 | 138 | if !(rpc == "git-upload-pack" || rpc == "git-receive-pack") { 139 | http.Error(w, "Not Found", 404) 140 | return 141 | } 142 | 143 | cmd, pipe := gitCommand(s.config.GitPath, subCommand(rpc), "--stateless-rpc", "--advertise-refs", r.RepoPath) 144 | if err := cmd.Start(); err != nil { 145 | fail500(w, context, err) 146 | return 147 | } 148 | defer cleanUpProcessGroup(cmd) 149 | 150 | w.Header().Add("Content-Type", fmt.Sprintf("application/x-%s-advertisement", rpc)) 151 | w.Header().Add("Cache-Control", "no-cache") 152 | w.WriteHeader(200) 153 | 154 | if err := packLine(w, fmt.Sprintf("# service=%s\n", rpc)); err != nil { 155 | logError(context, err) 156 | return 157 | } 158 | 159 | if err := packFlush(w); err != nil { 160 | logError(context, err) 161 | return 162 | } 163 | 164 | if _, err := io.Copy(w, pipe); err != nil { 165 | logError(context, err) 166 | return 167 | } 168 | 169 | if err := cmd.Wait(); err != nil { 170 | logError(context, err) 171 | return 172 | } 173 | } 174 | 175 | func (s *Server) postRPC(rpc string, w http.ResponseWriter, r *Request) { 176 | context := "post-rpc" 177 | body := r.Body 178 | 179 | if r.Header.Get("Content-Encoding") == "gzip" { 180 | var err error 181 | body, err = gzip.NewReader(r.Body) 182 | if err != nil { 183 | fail500(w, context, err) 184 | return 185 | } 186 | } 187 | 188 | cmd, pipe := gitCommand(s.config.GitPath, subCommand(rpc), "--stateless-rpc", r.RepoPath) 189 | defer pipe.Close() 190 | stdin, err := cmd.StdinPipe() 191 | if err != nil { 192 | fail500(w, context, err) 193 | return 194 | } 195 | defer stdin.Close() 196 | 197 | if err := cmd.Start(); err != nil { 198 | fail500(w, context, err) 199 | return 200 | } 201 | defer cleanUpProcessGroup(cmd) 202 | 203 | if _, err := io.Copy(stdin, body); err != nil { 204 | fail500(w, context, err) 205 | return 206 | } 207 | stdin.Close() 208 | 209 | w.Header().Add("Content-Type", fmt.Sprintf("application/x-%s-result", rpc)) 210 | w.Header().Add("Cache-Control", "no-cache") 211 | w.WriteHeader(200) 212 | 213 | if _, err := io.Copy(newWriteFlusher(w), pipe); err != nil { 214 | logError(context, err) 215 | return 216 | } 217 | if err := cmd.Wait(); err != nil { 218 | logError(context, err) 219 | return 220 | } 221 | } 222 | 223 | func (s *Server) Setup() error { 224 | return s.config.Setup() 225 | } 226 | 227 | func initRepo(name string, config *Config) error { 228 | fullPath := path.Join(config.Dir, name) 229 | 230 | if err := exec.Command(config.GitPath, "init", "--bare", fullPath).Run(); err != nil { 231 | return err 232 | } 233 | 234 | if config.AutoHooks && config.Hooks != nil { 235 | return config.Hooks.setupInDir(fullPath) 236 | } 237 | 238 | return nil 239 | } 240 | 241 | func repoExists(p string) bool { 242 | _, err := os.Stat(path.Join(p, "objects")) 243 | return err == nil 244 | } 245 | 246 | func gitCommand(name string, args ...string) (*exec.Cmd, io.ReadCloser) { 247 | cmd := exec.Command(name, args...) 248 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 249 | cmd.Env = os.Environ() 250 | 251 | r, _ := cmd.StdoutPipe() 252 | cmd.Stderr = cmd.Stdout 253 | 254 | return cmd, r 255 | } 256 | -------------------------------------------------------------------------------- /receiver.go: -------------------------------------------------------------------------------- 1 | package gitkit 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "os/exec" 8 | "path" 9 | "strings" 10 | 11 | "github.com/gofrs/uuid" 12 | ) 13 | 14 | const ZeroSHA = "0000000000000000000000000000000000000000" 15 | 16 | type Receiver struct { 17 | Debug bool 18 | MasterOnly bool 19 | TmpDir string 20 | HandlerFunc func(*HookInfo, string) error 21 | } 22 | 23 | func ReadCommitMessage(sha string) (string, error) { 24 | buff, err := exec.Command("git", "show", "-s", "--format=%B", sha).Output() 25 | if err != nil { 26 | return "", err 27 | } 28 | return strings.TrimSpace(string(buff)), nil 29 | } 30 | 31 | func IsForcePush(hook *HookInfo) (bool, error) { 32 | // New branch or tag OR deleted branch or tag 33 | if hook.OldRev == ZeroSHA || hook.NewRev == ZeroSHA { 34 | return false, nil 35 | } 36 | 37 | out, err := exec.Command("git", "merge-base", hook.OldRev, hook.NewRev).CombinedOutput() 38 | if err != nil { 39 | return false, fmt.Errorf("git merge base failed: %s", out) 40 | } 41 | 42 | base := strings.TrimSpace(string(out)) 43 | 44 | // Non fast-forwarded, meaning force 45 | return base != hook.OldRev, nil 46 | } 47 | 48 | func (r *Receiver) Handle(reader io.Reader) error { 49 | hook, err := ReadHookInput(reader) 50 | if err != nil { 51 | return err 52 | } 53 | 54 | if r.MasterOnly && hook.Ref != "refs/heads/master" { 55 | return fmt.Errorf("cant push to non-master branch") 56 | } 57 | 58 | id, err := uuid.NewV4() 59 | if err != nil { 60 | return fmt.Errorf("error generating new uuid: %v", err) 61 | } 62 | 63 | tmpDir := path.Join(r.TmpDir, id.String()) 64 | if err := os.MkdirAll(tmpDir, 0774); err != nil { 65 | return err 66 | } 67 | 68 | // Cleanup temp directory unless we're in debug mode 69 | if !r.Debug { 70 | defer os.RemoveAll(tmpDir) 71 | } 72 | 73 | archiveCmd := fmt.Sprintf("git archive '%s' | tar -x -C '%s'", hook.NewRev, tmpDir) 74 | buff, err := exec.Command("bash", "-c", archiveCmd).CombinedOutput() 75 | if err != nil { 76 | if len(buff) > 0 && strings.Contains(string(buff), "Damaged tar archive") { 77 | return fmt.Errorf("Error: repository might be empty!") 78 | } 79 | return fmt.Errorf("cant archive repo: %s", buff) 80 | } 81 | 82 | if r.HandlerFunc != nil { 83 | return r.HandlerFunc(hook, tmpDir) 84 | } 85 | 86 | return nil 87 | } 88 | -------------------------------------------------------------------------------- /ssh.go: -------------------------------------------------------------------------------- 1 | package gitkit 2 | 3 | import ( 4 | "bytes" 5 | "crypto/rand" 6 | "crypto/rsa" 7 | "crypto/x509" 8 | "encoding/pem" 9 | "errors" 10 | "fmt" 11 | "io" 12 | "io/ioutil" 13 | "log" 14 | "net" 15 | "os" 16 | "os/exec" 17 | "path/filepath" 18 | "strings" 19 | 20 | "golang.org/x/crypto/ssh" 21 | ) 22 | 23 | var ( 24 | ErrAlreadyStarted = errors.New("server has already been started") 25 | ErrNoListener = errors.New("cannot call Serve() before Listen()") 26 | ) 27 | 28 | type PublicKey struct { 29 | Id string 30 | Name string 31 | Fingerprint string 32 | Content string 33 | } 34 | 35 | type SSH struct { 36 | listener net.Listener 37 | 38 | sshconfig *ssh.ServerConfig 39 | config *Config 40 | PublicKeyLookupFunc func(string) (*PublicKey, error) 41 | } 42 | 43 | func NewSSH(config Config) *SSH { 44 | s := &SSH{config: &config} 45 | 46 | // Use PATH if full path is not specified 47 | if s.config.GitPath == "" { 48 | s.config.GitPath = "git" 49 | } 50 | return s 51 | } 52 | 53 | func fileExists(path string) bool { 54 | _, err := os.Stat(path) 55 | return err == nil || os.IsExist(err) 56 | } 57 | 58 | func cleanCommand(cmd string) string { 59 | i := strings.Index(cmd, "git") 60 | if i == -1 { 61 | return cmd 62 | } 63 | return cmd[i:] 64 | } 65 | 66 | func execCommandBytes(cmdname string, args ...string) ([]byte, []byte, error) { 67 | bufOut := new(bytes.Buffer) 68 | bufErr := new(bytes.Buffer) 69 | 70 | cmd := exec.Command(cmdname, args...) 71 | cmd.Stdout = bufOut 72 | cmd.Stderr = bufErr 73 | 74 | err := cmd.Run() 75 | return bufOut.Bytes(), bufErr.Bytes(), err 76 | } 77 | 78 | func execCommand(cmdname string, args ...string) (string, string, error) { 79 | bufOut, bufErr, err := execCommandBytes(cmdname, args...) 80 | return string(bufOut), string(bufErr), err 81 | } 82 | 83 | func (s *SSH) handleConnection(keyID string, chans <-chan ssh.NewChannel) { 84 | for newChan := range chans { 85 | if newChan.ChannelType() != "session" { 86 | newChan.Reject(ssh.UnknownChannelType, "unknown channel type") 87 | continue 88 | } 89 | 90 | ch, reqs, err := newChan.Accept() 91 | if err != nil { 92 | log.Printf("error accepting channel: %v", err) 93 | continue 94 | } 95 | 96 | go func(in <-chan *ssh.Request) { 97 | defer ch.Close() 98 | 99 | for req := range in { 100 | payload := cleanCommand(string(req.Payload)) 101 | 102 | switch req.Type { 103 | case "env": 104 | log.Printf("ssh: incoming env request: %s\n", payload) 105 | 106 | args := strings.Split(strings.Replace(payload, "\x00", "", -1), "\v") 107 | if len(args) != 2 { 108 | log.Printf("env: invalid env arguments: '%#v'", args) 109 | continue 110 | } 111 | 112 | args[0] = strings.TrimLeft(args[0], "\x04") 113 | if len(args[0]) == 0 { 114 | log.Printf("env: invalid key from payload: %s", payload) 115 | continue 116 | } 117 | 118 | _, _, err := execCommandBytes("env", args[0]+"="+args[1]) 119 | if err != nil { 120 | log.Printf("env: %v", err) 121 | return 122 | } 123 | case "exec": 124 | log.Printf("ssh: incoming exec request: %s\n", payload) 125 | 126 | cmdName := strings.TrimLeft(payload, "'()") 127 | log.Printf("ssh: payload '%v'", cmdName) 128 | 129 | if strings.HasPrefix(cmdName, "\x00") { 130 | cmdName = strings.Replace(cmdName, "\x00", "", -1)[1:] 131 | } 132 | 133 | gitcmd, err := ParseGitCommand(cmdName) 134 | if err != nil { 135 | log.Println("ssh: error parsing command:", err) 136 | ch.Write([]byte("Invalid command.\r\n")) 137 | return 138 | } 139 | 140 | if !repoExists(filepath.Join(s.config.Dir, gitcmd.Repo)) && s.config.AutoCreate { 141 | err := initRepo(gitcmd.Repo, s.config) 142 | if err != nil { 143 | logError("repo-init", err) 144 | return 145 | } 146 | } 147 | 148 | cmd := exec.Command(gitcmd.Command, gitcmd.Repo) 149 | cmd.Dir = s.config.Dir 150 | cmd.Env = append(os.Environ(), "GITKIT_KEY="+keyID) 151 | // cmd.Env = append(os.Environ(), "SSH_ORIGINAL_COMMAND="+cmdName) 152 | 153 | stdout, err := cmd.StdoutPipe() 154 | if err != nil { 155 | log.Printf("ssh: cant open stdout pipe: %v", err) 156 | return 157 | } 158 | 159 | stderr, err := cmd.StderrPipe() 160 | if err != nil { 161 | log.Printf("ssh: cant open stderr pipe: %v", err) 162 | return 163 | } 164 | 165 | input, err := cmd.StdinPipe() 166 | if err != nil { 167 | log.Printf("ssh: cant open stdin pipe: %v", err) 168 | return 169 | } 170 | 171 | if err = cmd.Start(); err != nil { 172 | log.Printf("ssh: start error: %v", err) 173 | return 174 | } 175 | 176 | req.Reply(true, nil) 177 | go io.Copy(input, ch) 178 | io.Copy(ch, stdout) 179 | io.Copy(ch.Stderr(), stderr) 180 | 181 | if err = cmd.Wait(); err != nil { 182 | log.Printf("ssh: command failed: %v", err) 183 | return 184 | } 185 | 186 | ch.SendRequest("exit-status", false, []byte{0, 0, 0, 0}) 187 | return 188 | default: 189 | ch.Write([]byte("Unsupported request type.\r\n")) 190 | log.Println("ssh: unsupported req type:", req.Type) 191 | return 192 | } 193 | } 194 | }(reqs) 195 | } 196 | } 197 | 198 | func (s *SSH) createServerKey() error { 199 | if err := os.MkdirAll(s.config.KeyDir, os.ModePerm); err != nil { 200 | return err 201 | } 202 | 203 | privateKey, err := rsa.GenerateKey(rand.Reader, 2048) 204 | if err != nil { 205 | return err 206 | } 207 | 208 | privateKeyFile, err := os.Create(s.config.KeyPath()) 209 | if err != nil { 210 | return err 211 | } 212 | 213 | if err := os.Chmod(s.config.KeyPath(), 0600); err != nil { 214 | return err 215 | } 216 | defer privateKeyFile.Close() 217 | if err != nil { 218 | return err 219 | } 220 | privateKeyPEM := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)} 221 | if err := pem.Encode(privateKeyFile, privateKeyPEM); err != nil { 222 | return err 223 | } 224 | 225 | pubKeyPath := s.config.KeyPath() + ".pub" 226 | pub, err := ssh.NewPublicKey(&privateKey.PublicKey) 227 | if err != nil { 228 | return err 229 | } 230 | return ioutil.WriteFile(pubKeyPath, ssh.MarshalAuthorizedKey(pub), 0644) 231 | } 232 | 233 | func (s *SSH) setup() error { 234 | if s.sshconfig != nil { 235 | return nil 236 | } 237 | config := &ssh.ServerConfig{ 238 | ServerVersion: fmt.Sprintf("SSH-2.0-gitkit %s", Version), 239 | } 240 | 241 | if s.config.KeyDir == "" { 242 | return fmt.Errorf("key directory is not provided") 243 | } 244 | 245 | if !s.config.Auth { 246 | config.NoClientAuth = true 247 | } else { 248 | if s.PublicKeyLookupFunc == nil { 249 | return fmt.Errorf("public key lookup func is not provided") 250 | } 251 | 252 | config.PublicKeyCallback = func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { 253 | pkey, err := s.PublicKeyLookupFunc(strings.TrimSpace(string(ssh.MarshalAuthorizedKey(key)))) 254 | if err != nil { 255 | return nil, err 256 | } 257 | 258 | if pkey == nil { 259 | return nil, fmt.Errorf("auth handler did not return a key") 260 | } 261 | 262 | return &ssh.Permissions{Extensions: map[string]string{"key-id": pkey.Id}}, nil 263 | } 264 | } 265 | 266 | keypath := s.config.KeyPath() 267 | if !fileExists(keypath) { 268 | if err := s.createServerKey(); err != nil { 269 | return err 270 | } 271 | } 272 | 273 | privateBytes, err := ioutil.ReadFile(keypath) 274 | if err != nil { 275 | return err 276 | } 277 | 278 | private, err := ssh.ParsePrivateKey(privateBytes) 279 | if err != nil { 280 | return err 281 | } 282 | 283 | config.AddHostKey(private) 284 | s.sshconfig = config 285 | return nil 286 | } 287 | 288 | func (s *SSH) Listen(bind string) error { 289 | if s.listener != nil { 290 | return ErrAlreadyStarted 291 | } 292 | 293 | if err := s.setup(); err != nil { 294 | return err 295 | } 296 | 297 | if err := s.config.Setup(); err != nil { 298 | return err 299 | } 300 | 301 | var err error 302 | s.listener, err = net.Listen("tcp", bind) 303 | if err != nil { 304 | return err 305 | } 306 | 307 | return nil 308 | } 309 | 310 | func (s *SSH) Serve() error { 311 | if s.listener == nil { 312 | return ErrNoListener 313 | } 314 | 315 | for { 316 | // wait for connection or Stop() 317 | conn, err := s.listener.Accept() 318 | if err != nil { 319 | return err 320 | } 321 | 322 | go func() { 323 | log.Printf("ssh: handshaking for %s", conn.RemoteAddr()) 324 | 325 | sConn, chans, reqs, err := ssh.NewServerConn(conn, s.sshconfig) 326 | if err != nil { 327 | if err == io.EOF { 328 | log.Printf("ssh: handshaking was terminated: %v", err) 329 | } else { 330 | log.Printf("ssh: error on handshaking: %v", err) 331 | } 332 | return 333 | } 334 | 335 | log.Printf("ssh: connection from %s (%s)", sConn.RemoteAddr(), sConn.ClientVersion()) 336 | 337 | if s.config.Auth && s.config.GitUser != "" && sConn.User() != s.config.GitUser { 338 | sConn.Close() 339 | return 340 | } 341 | 342 | keyId := "" 343 | if sConn.Permissions != nil { 344 | keyId = sConn.Permissions.Extensions["key-id"] 345 | } 346 | 347 | go ssh.DiscardRequests(reqs) 348 | go s.handleConnection(keyId, chans) 349 | }() 350 | } 351 | } 352 | 353 | func (s *SSH) ListenAndServe(bind string) error { 354 | if err := s.Listen(bind); err != nil { 355 | return err 356 | } 357 | return s.Serve() 358 | } 359 | 360 | // Stop stops the server if it has been started, otherwise it is a no-op. 361 | func (s *SSH) Stop() error { 362 | if s.listener == nil { 363 | return nil 364 | } 365 | defer func() { 366 | s.listener = nil 367 | }() 368 | 369 | return s.listener.Close() 370 | } 371 | 372 | // Address returns the network address of the listener. This is in 373 | // particular useful when binding to :0 to get a free port assigned by 374 | // the OS. 375 | func (s *SSH) Address() string { 376 | if s.listener != nil { 377 | return s.listener.Addr().String() 378 | } 379 | return "" 380 | } 381 | 382 | // SetSSHConfig can be used to set custom SSH Server settings. 383 | func (s *SSH) SetSSHConfig(cfg *ssh.ServerConfig) { 384 | s.sshconfig = cfg 385 | } 386 | 387 | // SetListener can be used to set custom Listener. 388 | func (s *SSH) SetListener(l net.Listener) { 389 | s.listener = l 390 | } 391 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package gitkit 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "net/http" 8 | "os/exec" 9 | "regexp" 10 | "strings" 11 | "syscall" 12 | ) 13 | 14 | var reSlashDedup = regexp.MustCompile(`\/{2,}`) 15 | 16 | func fail500(w http.ResponseWriter, context string, err error) { 17 | http.Error(w, "Internal server error", 500) 18 | logError(context, err) 19 | } 20 | 21 | func logError(context string, err error) { 22 | log.Printf("%s: %v\n", context, err) 23 | } 24 | 25 | func logInfo(context string, message string) { 26 | log.Printf("%s: %s\n", context, message) 27 | } 28 | 29 | func cleanUpProcessGroup(cmd *exec.Cmd) { 30 | if cmd == nil { 31 | return 32 | } 33 | 34 | process := cmd.Process 35 | if process != nil && process.Pid > 0 { 36 | syscall.Kill(-process.Pid, syscall.SIGTERM) 37 | } 38 | 39 | go cmd.Wait() 40 | } 41 | 42 | func packLine(w io.Writer, s string) error { 43 | _, err := fmt.Fprintf(w, "%04x%s", len(s)+4, s) 44 | return err 45 | } 46 | 47 | func packFlush(w io.Writer) error { 48 | _, err := fmt.Fprint(w, "0000") 49 | return err 50 | } 51 | 52 | func subCommand(rpc string) string { 53 | return strings.TrimPrefix(rpc, "git-") 54 | } 55 | 56 | // Parse out namespace and repository name from the path. 57 | // Examples: 58 | // repo -> "", "repo" 59 | // org/repo -> "org", "repo" 60 | // org/suborg/rpeo -> "org/suborg", "repo" 61 | func getNamespaceAndRepo(input string) (string, string) { 62 | if input == "" || input == "/" { 63 | return "", "" 64 | } 65 | 66 | // Remove duplicate slashes 67 | input = reSlashDedup.ReplaceAllString(input, "/") 68 | 69 | // Remove leading slash 70 | if input[0] == '/' && input != "/" { 71 | input = input[1:] 72 | } 73 | 74 | blocks := strings.Split(input, "/") 75 | num := len(blocks) 76 | 77 | if num < 2 { 78 | return "", blocks[0] 79 | } 80 | 81 | return strings.Join(blocks[0:num-1], "/"), blocks[num-1] 82 | } 83 | -------------------------------------------------------------------------------- /utils_test.go: -------------------------------------------------------------------------------- 1 | package gitkit 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func Test_subCommand(t *testing.T) { 11 | cases := map[string]string{ 12 | "git-receive-pack": "receive-pack", 13 | "git-upload-pack": "upload-pack", 14 | "git-foobar": "foobar", 15 | "git": "git", 16 | "foobar": "foobar", 17 | } 18 | 19 | for example, expected := range cases { 20 | result := subCommand(example) 21 | if result != expected { 22 | t.Errorf("Expected %s, got %s", expected, result) 23 | } 24 | } 25 | } 26 | 27 | func Test_packFlush(t *testing.T) { 28 | w := bytes.NewBuffer([]byte{}) 29 | err := packFlush(w) 30 | 31 | if err != nil { 32 | t.Errorf("Expected no error, got %v", err) 33 | } 34 | 35 | if w.String() != "0000" { 36 | t.Errorf("Expected 0000, got %v", w.String()) 37 | } 38 | } 39 | 40 | func Test_packLine(t *testing.T) { 41 | cases := map[string]string{ 42 | "": "0004", 43 | "0": "00050", 44 | "10": "000610", 45 | "100": "0007100", 46 | "1000": "00081000", 47 | } 48 | 49 | w := bytes.NewBuffer([]byte{}) 50 | 51 | for example, expected := range cases { 52 | w.Reset() 53 | err := packLine(w, example) 54 | 55 | assert.NoError(t, err) 56 | assert.Equal(t, expected, w.String()) 57 | } 58 | } 59 | 60 | func Test_getNamespaceAndRepo(t *testing.T) { 61 | cases := map[string][]string{ 62 | "": {"", ""}, 63 | "/": {"", ""}, 64 | "///": {"", ""}, 65 | "/repo": {"", "repo"}, 66 | "/org/repo": {"org", "repo"}, 67 | "/org/suborg/repo": {"org/suborg", "repo"}, 68 | "//org//org///repo": {"org/org", "repo"}, 69 | } 70 | 71 | for example, expected := range cases { 72 | namespace, repo := getNamespaceAndRepo(example) 73 | 74 | assert.Equal(t, expected[0], namespace) 75 | assert.Equal(t, expected[1], repo) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package gitkit 2 | 3 | const Version = "0.4.0" 4 | -------------------------------------------------------------------------------- /write_flusher.go: -------------------------------------------------------------------------------- 1 | package gitkit 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | ) 7 | 8 | func newWriteFlusher(w http.ResponseWriter) io.Writer { 9 | return writeFlusher{w.(interface { 10 | io.Writer 11 | http.Flusher 12 | })} 13 | } 14 | 15 | type writeFlusher struct { 16 | wf interface { 17 | io.Writer 18 | http.Flusher 19 | } 20 | } 21 | 22 | func (w writeFlusher) Write(p []byte) (int, error) { 23 | defer w.wf.Flush() 24 | return w.wf.Write(p) 25 | } 26 | --------------------------------------------------------------------------------