├── .gitignore ├── LICENSE ├── README.md └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | id_rsa 14 | id_rsa.pub 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Etienne Stalmans 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ssh-git-go 2 | 3 | ## What is it? 4 | 5 | A really small and silly SSH server that allows anonymous access to git repositories. This means you can allow cloning of a repository over SSH without the user having a valid account on the server. 6 | 7 | ## Why is this useful? 8 | 9 | Generally there shouldn't be a reason to do this, since the http:// handler already makes it easy to give anonymous access to repositories. In some instances, such as an exploit in git, it might be useful to allow anonymous access where a user isn't prompted for credentials etc. 10 | 11 | The downside of SSH is that host key verification is enforced, meaning it still requires some interaction by the user to accept the host key. But once that host key is accepted, it is smooth sailing. 12 | 13 | ## How to use 14 | 15 | Grab the code: 16 | 17 | ```bash 18 | go get github.com/staaldraad/ssh-git-go 19 | ``` 20 | 21 | Then use: 22 | 23 | ```bash 24 | ./ssh-git-go -h 25 | Usage of ssh-git-go: 26 | -d string 27 | The directory where the git repositories are (default "./repos") 28 | -i string 29 | The interface to listen on (default "0.0.0.0") 30 | -p int 31 | Port to use (default 2221) 32 | -s string 33 | Where to find the host-key (default "./id_rsa") 34 | ``` 35 | 36 | The usage options should be pretty self-explanitory. Before running you'll need to generate a host-key for SSH to use, to do this use `ssh-keygen`: 37 | 38 | ```bash 39 | ssh-keygen -t rsa 40 | ``` 41 | 42 | The `-d` parameter allows specifying the location where your git repositories live (these have to be bare repositories). 43 | 44 | ### Example 45 | 46 | Run the server: 47 | 48 | ```bash 49 | ./ssh-git-go -p 2221 -d /tmp/repos 50 | 2018/10/18 16:42:32 New listener started on 0.0.0.0:2221 51 | 2018/10/18 16:42:32 Serving repositories found in /tmp/repos 52 | ``` 53 | 54 | Now a normal `git clone` should work against the server. Lets assume there is a folder called `meh.git` in the `/tmp/repos` directory and this has the bare repository. 55 | 56 | On the client: 57 | 58 | ```bash 59 | git clone ssh://serveraddress:2221/meh.git 60 | Cloning into 'meh'... 61 | remote: Counting objects: 3, done. 62 | Remote: Total 3 (delta 0), reused 0 (delta 0) 63 | Receiving objects: 100% (3/3), done. 64 | ``` 65 | 66 | On the server you should see: 67 | 68 | ```bash 69 | 2018/10/18 16:46:53 New SSH connection from [::1]:60864 (SSH-2.0-OpenSSH_7.4p1 Debian-10+deb9u4) 70 | 2018/10/18 16:46:53 Requesting repo: /tmp/repos/meh.git 71 | ``` 72 | 73 | # License 74 | 75 | Made with tears by @staaldraad and distributed under [MIT](https://github.com/staaldraad/ssh-git-go/blob/master/LICENSE) license. 76 | 77 | Kudos and hate on Twitter: [@_staaldraad](https://twitter.com/_staaldraad) -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "log" 9 | "net" 10 | "os" 11 | "os/exec" 12 | "path" 13 | "strings" 14 | 15 | "golang.org/x/crypto/ssh" 16 | ) 17 | 18 | var reposLocation string 19 | 20 | func main() { 21 | portPtr := flag.Int("p", 2221, "Port to use") 22 | dirPtr := flag.String("d", "./repos", "The directory where the git repositories are") 23 | interfacePtr := flag.String("i", "0.0.0.0", "The interface to listen on") 24 | hostkeyPtr := flag.String("s", "./id_rsa", "Where to find the host-key") 25 | 26 | flag.Parse() 27 | 28 | reposLocation = *dirPtr 29 | if strings.HasPrefix(reposLocation, "./") { 30 | wd, _ := os.Getwd() 31 | reposLocation = fmt.Sprintf("%s/%s", wd, strings.TrimPrefix(reposLocation, "./")) 32 | } 33 | 34 | // check that the supplied repos directory actually exists 35 | if _, err := os.Stat(reposLocation); err != nil { 36 | log.Fatalf("Couldn't access supplied git directory: %q", err) 37 | } 38 | 39 | config := &ssh.ServerConfig{ 40 | //Explicitely set "none" auth as valid. This is wanted to allow anonymous SSH 41 | NoClientAuth: true, 42 | } 43 | 44 | privateBytes, err := ioutil.ReadFile(*hostkeyPtr) 45 | if err != nil { 46 | log.Printf("If you need to generate a host key, use: ssh-keygen -t rsa") 47 | log.Fatalf("Failed to load private key %s (%q)", *hostkeyPtr, err) 48 | } 49 | 50 | private, err := ssh.ParsePrivateKey(privateBytes) 51 | if err != nil { 52 | log.Fatalf("Failed to parse private key: %q", err) 53 | } 54 | 55 | config.AddHostKey(private) 56 | 57 | listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", *interfacePtr, *portPtr)) 58 | if err != nil { 59 | log.Fatalf("Failed to listen on %d (%s)", *portPtr, err) 60 | } 61 | 62 | log.Printf("New listener started on %s:%d", *interfacePtr, *portPtr) 63 | log.Printf("Serving repositories found in %s", reposLocation) 64 | 65 | for { 66 | tcpConn, err := listener.Accept() 67 | if err != nil { 68 | log.Printf("Failed to accept incoming connection (%q)", err) 69 | continue 70 | } 71 | // Before use, a handshake must be performed on the incoming net.Conn. 72 | sshConn, chans, reqs, err := ssh.NewServerConn(tcpConn, config) 73 | if err != nil { 74 | log.Printf("Failed to handshake (%s)", err) 75 | continue 76 | } 77 | 78 | log.Printf("New SSH connection from %s (%s)", sshConn.RemoteAddr(), sshConn.ClientVersion()) 79 | 80 | // Accept all channels but we only really want "x" 81 | go handleConnection(reqs, chans) 82 | } 83 | } 84 | 85 | func handleConnection(reqs <-chan *ssh.Request, chans <-chan ssh.NewChannel) { 86 | 87 | go func(reqs <-chan *ssh.Request) { 88 | for r := range reqs { 89 | if r.WantReply { 90 | r.Reply(false, nil) 91 | } 92 | } 93 | }(reqs) 94 | 95 | // Service the incoming Channel channel in go routine 96 | for ch := range chans { 97 | if t := ch.ChannelType(); t == "session" { 98 | 99 | channel, requests, err := ch.Accept() 100 | if err != nil { 101 | log.Printf("Could not accept channel (%s)", err) 102 | return 103 | } 104 | go handleChannel(channel, requests) 105 | } else { 106 | ch.Reject(ssh.Prohibited, "neeeeeerp") 107 | } 108 | } 109 | } 110 | func handleChannel(channel ssh.Channel, requests <-chan *ssh.Request) { 111 | defer channel.Close() 112 | 113 | for req := range requests { 114 | 115 | // only going to respond to exec. 116 | // and then only to git-upload-pack 117 | if req.Type == "exec" { 118 | if req.WantReply { 119 | req.Reply(true, []byte{}) 120 | } 121 | 122 | go func() { 123 | handleExecChannel(channel, req) 124 | channel.Close() 125 | }() 126 | } else { 127 | if req.WantReply { 128 | req.Reply(false, nil) 129 | } 130 | } 131 | } 132 | 133 | } 134 | 135 | type execRequestMsg struct { 136 | Command string 137 | } 138 | 139 | type exitStatusMsg struct { 140 | Status uint32 141 | } 142 | 143 | func handleExecChannel(channel ssh.Channel, req *ssh.Request) { 144 | 145 | var msg execRequestMsg 146 | ssh.Unmarshal(req.Payload, &msg) 147 | 148 | // Only want the git-upload-pack 149 | parts := strings.Split(msg.Command, " ") 150 | 151 | // we only expect git-upload-pack repo.git 152 | if len(parts) != 2 { 153 | req.Reply(false, nil) 154 | return 155 | } 156 | 157 | if 0 == strings.Compare(parts[0], "git-upload-pack") { 158 | 159 | // ensure supplied path does contain dir traversal 160 | p := path.Clean(parts[1]) 161 | 162 | // get rid of enclosing '' 163 | p = strings.Trim(p, "'") 164 | 165 | fullPath := path.Join(reposLocation, p) 166 | log.Printf("Requesting repo: %s\n", fullPath) 167 | 168 | // check that fullPath exists 169 | if _, err := os.Stat(fullPath); err != nil { 170 | log.Printf("Couldn't access requested git directory: %q", err) 171 | channel.SendRequest("exit-status", false, ssh.Marshal(&exitStatusMsg{1})) 172 | } else { 173 | res := doExec(channel, channel, channel.Stderr(), fullPath) 174 | channel.SendRequest("exit-status", false, ssh.Marshal(&exitStatusMsg{res})) 175 | } 176 | } 177 | } 178 | 179 | func doExec(stdin io.Reader, stdout, stderr io.Writer, path string) uint32 { 180 | 181 | cmd := exec.Command("/usr/bin/git-upload-pack", "--strict", path) 182 | cmd.Dir = path 183 | cmd.Env = []string{fmt.Sprintf("GIT_DIR=%s", path)} 184 | cmd.Stdin = stdin 185 | cmd.Stdout = stdout 186 | cmd.Stderr = stderr 187 | 188 | if err := cmd.Run(); err != nil { 189 | log.Printf("Error occurred: %q\n", err) 190 | return 1 191 | } 192 | return 0 193 | } 194 | --------------------------------------------------------------------------------