diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b35171f..205e757 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,7 +4,7 @@ on: - push env: - GO_VERSION: 1.18 + GO_VERSION: 1.19 jobs: build: @@ -14,12 +14,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: fetch-depth: 0 - name: Setup Go - uses: actions/setup-go@v2 + uses: actions/setup-go@v3 with: go-version: ${{ env.GO_VERSION }} diff --git a/README.md b/README.md index 5c68227..ba182d6 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,7 @@ Paste the following: ``` Host localhost Port 2222 + ForwardAgent no ``` Now that the server is configured, we can fire it up: diff --git a/go.mod b/go.mod index 1fc9f41..6e5eb54 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,19 @@ module github.com/sosedoff/gitkit -go 1.16 +go 1.19 require ( github.com/gofrs/uuid v4.0.0+incompatible github.com/stretchr/testify v1.7.0 - golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a + golang.org/x/crypto v0.9.0 + golang.org/x/exp v0.0.0-20230203172020-98cc5a0785f9 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/sys v0.8.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 1607b45..d05d469 100644 --- a/go.sum +++ b/go.sum @@ -1,22 +1,30 @@ -github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw= github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a h1:kr2P4QFmQr29mSLA43kwrOcgcReGTfbE9N577tCTuBc= -golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g= +golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= +golang.org/x/exp v0.0.0-20230203172020-98cc5a0785f9 h1:frX3nT9RkKybPnjyI+yvZh6ZucTZatCCEm9D47sZ2zo= +golang.org/x/exp v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= +golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.8.0 h1:n5xxQn2i3PC0yLAbjTpNT85q/Kgzcr2gIoX9OrJUols= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/key.go b/key.go new file mode 100644 index 0000000..c649a08 --- /dev/null +++ b/key.go @@ -0,0 +1,72 @@ +package gitkit + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "os" + "path/filepath" + + "golang.org/x/crypto/ssh" +) + +type Key struct { + keyDir string + keyName string +} + +func NewKey(keyDir string) *Key { + return &Key{ + keyDir: keyDir, + keyName: "gitkit.rsa", + } +} + +func (k *Key) CreateRSA() error { + if err := os.MkdirAll(k.keyDir, os.ModePerm); err != nil { + return err + } + + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return err + } + + keyPath := filepath.Join(k.keyDir, k.keyName) + + privateKeyFile, err := os.Create(keyPath) + if err != nil { + return err + } + + if err := os.Chmod(keyPath, 0600); err != nil { + return err + } + defer privateKeyFile.Close() + if err != nil { + return err + } + privateKeyPEM := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)} + if err := pem.Encode(privateKeyFile, privateKeyPEM); err != nil { + return err + } + + pubKeyPath := keyPath + ".pub" + pub, err := ssh.NewPublicKey(&privateKey.PublicKey) + if err != nil { + return err + } + + return os.WriteFile(pubKeyPath, ssh.MarshalAuthorizedKey(pub), 0644) +} + +func (k *Key) GetRSA() (ssh.Signer, error) { + keyPath := filepath.Join(k.keyDir, k.keyName) + privateBytes, err := os.ReadFile(keyPath) + if err != nil { + return nil, err + } + + return ssh.ParsePrivateKey(privateBytes) +} diff --git a/receiver.go b/receiver.go index f4a02fb..29ade01 100644 --- a/receiver.go +++ b/receiver.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/gofrs/uuid" + "golang.org/x/exp/slices" ) const ZeroSHA = "0000000000000000000000000000000000000000" @@ -16,6 +17,7 @@ const ZeroSHA = "0000000000000000000000000000000000000000" type Receiver struct { Debug bool MasterOnly bool + AllowedRefs []string TmpDir string HandlerFunc func(*HookInfo, string) error } @@ -45,14 +47,30 @@ func IsForcePush(hook *HookInfo) (bool, error) { return base != hook.OldRev, nil } +func (r *Receiver) CheckAllowedBranch(hook *HookInfo) error { + if r.MasterOnly { // for BC + r.AllowedRefs = append(r.AllowedRefs, "refs/heads/master") + } + + if len(r.AllowedRefs) == 0 { + return nil + } + + if !slices.Contains(r.AllowedRefs, hook.Ref) { + return fmt.Errorf("cannot push branch, allowed branches: %s", strings.Join(r.AllowedRefs, ", ")) + } + + return nil +} + func (r *Receiver) Handle(reader io.Reader) error { hook, err := ReadHookInput(reader) if err != nil { return err } - if r.MasterOnly && hook.Ref != "refs/heads/master" { - return fmt.Errorf("cant push to non-master branch") + if err = r.CheckAllowedBranch(hook); err != nil { + return err } id, err := uuid.NewV4() diff --git a/receiver_test.go b/receiver_test.go new file mode 100644 index 0000000..2c6309f --- /dev/null +++ b/receiver_test.go @@ -0,0 +1,91 @@ +package gitkit_test + +import ( + "fmt" + "testing" + + "github.com/sosedoff/gitkit" + "github.com/stretchr/testify/assert" +) + +type gitReceiveMock struct { + name string + masterOnly bool + allowedBranches []string + ref string + err error +} + +func TestMasterOnly(t *testing.T) { + testCases := []gitReceiveMock{ + { + name: "push to master, no error", + masterOnly: true, + ref: "refs/heads/master", + err: nil, + }, + { + name: "push to a branch, should trigger error", + masterOnly: true, + ref: "refs/heads/branch", + err: fmt.Errorf("cannot push branch, allowed branches: refs/heads/master"), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + r := &gitkit.Receiver{ + MasterOnly: tc.masterOnly, + } + + err := r.CheckAllowedBranch(&gitkit.HookInfo{ + Ref: tc.ref, + }) + + assert.Equal(t, tc.err, err) + }) + } +} + +func TestAllowedBranches(t *testing.T) { + testCases := []gitReceiveMock{ + { + name: "push to master, no error", + allowedBranches: []string{"refs/heads/master"}, + ref: "refs/heads/master", + err: nil, + }, + { + name: "push to a branch, should trigger error", + allowedBranches: []string{"refs/heads/master"}, + ref: "refs/heads/some-branch", + err: fmt.Errorf("cannot push branch, allowed branches: refs/heads/master"), + }, + { + name: "push to another-branch", + allowedBranches: []string{"refs/heads/another-branch"}, + ref: "refs/heads/another-branch", + err: nil, + }, + { + name: "push to main and only allow main", + allowedBranches: []string{"refs/heads/main"}, + ref: "refs/heads/main", + err: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + r := &gitkit.Receiver{ + AllowedRefs: tc.allowedBranches, + } + + err := r.CheckAllowedBranch(&gitkit.HookInfo{ + Ref: tc.ref, + }) + + assert.Equal(t, tc.err, err) + }) + } +} diff --git a/ssh.go b/ssh.go index 5712a7e..3654d9a 100644 --- a/ssh.go +++ b/ssh.go @@ -2,14 +2,9 @@ package gitkit import ( "bytes" - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "encoding/pem" "errors" "fmt" "io" - "io/ioutil" "log" "net" "os" @@ -35,9 +30,10 @@ type PublicKey struct { type SSH struct { listener net.Listener - sshconfig *ssh.ServerConfig - config *Config - PublicKeyLookupFunc func(string) (*PublicKey, error) + sshconfig *ssh.ServerConfig + config *Config + PublicKeyLookupFunc func(string) (*PublicKey, error) + ReposForKeyLookupFunc func(*PublicKey) ([]string, error) } func NewSSH(config Config) *SSH { @@ -80,7 +76,7 @@ func execCommand(cmdname string, args ...string) (string, string, error) { return string(bufOut), string(bufErr), err } -func (s *SSH) handleConnection(keyID string, chans <-chan ssh.NewChannel) { +func (s *SSH) handleConnection(exts map[string]string, chans <-chan ssh.NewChannel) { for newChan := range chans { if newChan.ChannelType() != "session" { newChan.Reject(ssh.UnknownChannelType, "unknown channel type") @@ -147,7 +143,15 @@ func (s *SSH) handleConnection(keyID string, chans <-chan ssh.NewChannel) { cmd := exec.Command(gitcmd.Command, gitcmd.Repo) cmd.Dir = s.config.Dir - cmd.Env = append(os.Environ(), "GITKIT_KEY="+keyID) + + envVariables := os.Environ() + // append data via ssh.Permissions.Extensions + for k, v := range exts { + log.Println("k=" + k + ", v=" + v) + envVariables = append(envVariables, "GITKIT_"+strings.ToUpper(k)+"="+v) + } + cmd.Env = envVariables + // cmd.Env = append(os.Environ(), "SSH_ORIGINAL_COMMAND="+cmdName) stdout, err := cmd.StdoutPipe() @@ -195,41 +199,6 @@ func (s *SSH) handleConnection(keyID string, chans <-chan ssh.NewChannel) { } } -func (s *SSH) createServerKey() error { - if err := os.MkdirAll(s.config.KeyDir, os.ModePerm); err != nil { - return err - } - - privateKey, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - return err - } - - privateKeyFile, err := os.Create(s.config.KeyPath()) - if err != nil { - return err - } - - if err := os.Chmod(s.config.KeyPath(), 0600); err != nil { - return err - } - defer privateKeyFile.Close() - if err != nil { - return err - } - privateKeyPEM := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)} - if err := pem.Encode(privateKeyFile, privateKeyPEM); err != nil { - return err - } - - pubKeyPath := s.config.KeyPath() + ".pub" - pub, err := ssh.NewPublicKey(&privateKey.PublicKey) - if err != nil { - return err - } - return ioutil.WriteFile(pubKeyPath, ssh.MarshalAuthorizedKey(pub), 0644) -} - func (s *SSH) setup() error { if s.sshconfig != nil { return nil @@ -249,6 +218,10 @@ func (s *SSH) setup() error { return fmt.Errorf("public key lookup func is not provided") } + if s.ReposForKeyLookupFunc == nil { + log.Println("no repository callback, an authorized user may access any repositories") + } + config.PublicKeyCallback = func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { pkey, err := s.PublicKeyLookupFunc(strings.TrimSpace(string(ssh.MarshalAuthorizedKey(key)))) if err != nil { @@ -259,23 +232,36 @@ func (s *SSH) setup() error { return nil, fmt.Errorf("auth handler did not return a key") } - return &ssh.Permissions{Extensions: map[string]string{"key-id": pkey.Id}}, nil + var repos []string + + if s.ReposForKeyLookupFunc != nil { + repos, err = s.ReposForKeyLookupFunc(pkey) + if err != nil { + return nil, err + } + } + + return &ssh.Permissions{ + Extensions: map[string]string{ + "key": pkey.Id, + "fingerprint": pkey.Fingerprint, + "name": pkey.Name, + "repositories": strings.Join(repos, ","), + }, + }, nil } } - keypath := s.config.KeyPath() - if !fileExists(keypath) { - if err := s.createServerKey(); err != nil { + keyPath := s.config.KeyPath() + + k := NewKey(s.config.KeyDir) + if !fileExists(keyPath) { + if err := k.CreateRSA(); err != nil { return err } } - privateBytes, err := ioutil.ReadFile(keypath) - if err != nil { - return err - } - - private, err := ssh.ParsePrivateKey(privateBytes) + private, err := k.GetRSA() if err != nil { return err } @@ -339,13 +325,13 @@ func (s *SSH) Serve() error { return } - keyId := "" + var exts map[string]string if sConn.Permissions != nil { - keyId = sConn.Permissions.Extensions["key-id"] + exts = sConn.Permissions.Extensions } go ssh.DiscardRequests(reqs) - go s.handleConnection(keyId, chans) + go s.handleConnection(exts, chans) }() } } diff --git a/ssh_test.go b/ssh_test.go new file mode 100644 index 0000000..ca944fa --- /dev/null +++ b/ssh_test.go @@ -0,0 +1,85 @@ +package gitkit_test + +import ( + "errors" + "fmt" + "net" + "path/filepath" + "testing" + + "github.com/sosedoff/gitkit" + "github.com/stretchr/testify/assert" + "golang.org/x/crypto/ssh" +) + +func TestKeyLookupFunctionIsNeeded(t *testing.T) { + s := newSSH(t, t.TempDir()) + + err := s.Listen(":0") // random port + assert.Equal(t, errors.New("public key lookup func is not provided"), err) +} + +func TestListener(t *testing.T) { + testDir := t.TempDir() + + s := newSSH(t, testDir) + s.PublicKeyLookupFunc = func(content string) (*gitkit.PublicKey, error) { + return &gitkit.PublicKey{Id: "1234"}, nil + } + + sshdConfig, err := setup(t, testDir) + assert.NoError(t, err) + + s.SetSSHConfig(sshdConfig) + + listener, err := net.Listen("tcp4", ":0") + assert.NoError(t, err) + + s.SetListener(listener) + t.Logf("address: %s", listener.Addr()) + + go func() { + defer s.Stop() + err := s.Serve() + assert.NoError(t, err) + }() + + // assert the keys are created + assert.FileExists(t, filepath.Join(testDir, "keys/gitkit.rsa")) + assert.FileExists(t, filepath.Join(testDir, "keys/gitkit.rsa.pub")) +} + +func newSSH(t *testing.T, baseDir string) *gitkit.SSH { + t.Helper() + + return gitkit.NewSSH(gitkit.Config{ + Auth: true, + AutoCreate: true, + KeyDir: filepath.Join(baseDir, "keys"), + Dir: filepath.Join(baseDir, "repos"), + }) +} + +// custom setup function to replicate what gitkit does to setup the ssh server, +// but doesn't do when you supply a custom listener +func setup(t *testing.T, dir string) (*ssh.ServerConfig, error) { + t.Helper() + + config := &ssh.ServerConfig{ + ServerVersion: fmt.Sprintf("SSH-2.0-gitkit %s", "testing"), + } + + config.NoClientAuth = true + + // create server key + k := gitkit.NewKey(filepath.Join(dir, "keys")) + + err := k.CreateRSA() + assert.NoError(t, err) + + private, err := k.GetRSA() + assert.NoError(t, err) + + config.AddHostKey(private) + return config, nil +}