initial commit, bunch of work, nothing to write home about yet
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/.hgignore Wed Apr 17 18:04:18 2019 -0500
@@ -0,0 +1,4 @@
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/README.md Wed Apr 17 18:04:18 2019 -0500
@@ -0,0 +1,93 @@
+hgkeeper is an server for [mercurial](https://www.mercurial-scm.org/) +repositories. It provides access control for SSH access. +This project is brand new and not even functional yet... But if you're +interested in helping, please do!! +hgkeeper has a couple modes of operation but `serve` is the main mode. +The `setup` command is used to bootstrap hgkeeper. It will create the +directory for the repositores, the hgkeeper repository, and create an initial +The `serve` command is the main mode of operation which is to provide access to +Controlling access to the repositories is done via the `hgkeeper` repository. +The repository has a specific layout to make it easier to reason about. +The keys directory contains a list of files that contain one or many SSH public +keys. It is entirely up to you on how to name these files and their contents, +but note that the filename is used in `access.yml` to delegate permissions. +`access.yml` is the access control configuration. When you initial setup +`hgkeeper` you will get an `access.yml` like the following. +There's a lot going on here, so let's talk about the basics here first. Access +is granted to user via the file name in the `keys/` directory or the name of a +The `groups` section contains the name of the group and the list of keys that +are in that group. So in the above example, the `admins` group has one file +who's keys will be put into the `admins` group. You can add a group to another +group. There is also a special built-in group named `public` which will allow +anyone to access the repository. +Now that we have a basic understanding of how keys are specified, we can cover +how to grant and revoke their permissions to specific repositories. +The `global` and `patterns` sections use a simple format to specify a list of +which keys are allowed to init (create), read, and write repositories. +The `global` section contains the defaults for all repositories. In the above +example, it gives permission to `admins` to create repositories anywhere and +allows all users to read all repositories. These permissions will be used +only if a repository's matching pattern does not specify a value for this + The `patterns` section uses a key of a glob of what repositories to apply + these changes to. Since this is a glob pattern, that means it'll allow `*` + and `?` for wildcards. If an entry in a `patterns` entry does not specify + any of the init, read, and/or write permissions, the corresponding value from + the global section will be used. \ No newline at end of file
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/access.yml Wed Apr 17 18:04:18 2019 -0500
@@ -0,0 +1,25 @@
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/access/access.go Wed Apr 17 18:04:18 2019 -0500
@@ -0,0 +1,27 @@
+ "github.com/go-yaml/yaml" + Init []string `yaml:"init"` + Read []string `yaml:"read"` + Write []string `yaml:"write"` +type AccessControl struct { + Global acl `yaml:"global"` + Groups map[string][]string `yaml:"groups"` + Patterns map[string]acl `yaml:"patterns"` +func loadAccessControl(r io.Reader) (*AccessControl, error) { + err := yaml.NewDecoder(r).Decode(ac) --- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/access/access_test.go Wed Apr 17 18:04:18 2019 -0500
@@ -0,0 +1,47 @@
+ "github.com/stretchr/testify/assert" +func TestAccessControlLoadSimple(t *testing.T) { + assert := assert.New(t) + fmt.Printf("data: %#v\n", data) + ac, err := loadAccessControl(strings.NewReader(data)) + fmt.Printf("ac: %#v\n", ac) + Init: []string{"admin"}, + Read: []string{"everyone"}, + Read: []string{"admin"}, + ac.Patterns["hgkeeper"], --- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/go.mod Wed Apr 17 18:04:18 2019 -0500
@@ -0,0 +1,10 @@
+module bitbucket.org/rw_grim/hgkeeper + github.com/alecthomas/kong v0.1.16 + github.com/go-yaml/yaml v2.1.0+incompatible + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 + github.com/sirupsen/logrus v1.4.1 + github.com/stretchr/testify v1.3.0 + golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a --- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/go.sum Wed Apr 17 18:04:18 2019 -0500
@@ -0,0 +1,25 @@
+github.com/alecthomas/kong v0.1.16 h1:BheBKuvr6FE1unlZVdqkdZo/D/eDu90rrVIlpPbOdgw= +github.com/alecthomas/kong v0.1.16/go.mod h1:0m2VYms8rH0qbCqVB2gvGHk74bqLIq0HXjCs5bNbNQU= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +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/go-yaml/yaml v2.1.0+incompatible h1:RYi2hDdss1u4YE7GwixGzWwVo47T8UQwnTLB6vQiq+o= +github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +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/sirupsen/logrus v1.4.1 h1:GL2rEmy6nsikmW0r8opw9JIRScdMF5hA8cOYLH7In1k= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a h1:Igim7XhdOpBnWPuYJ70XcNpq8q3BCACtVgNfoJxOV7g= +golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e h1:nFYrTHrdrAOpShe27kaFHjsqYSEQ0KWqdWLu3xuZJts= +golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= --- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/hg/commands.go Wed Apr 17 18:04:18 2019 -0500
@@ -0,0 +1,44 @@
+ "github.com/alecthomas/kong" + "github.com/kballard/go-shellquote" + Repo string `flag short:"R"` + Stdio bool `flag name:"stdio"` +func RepoFromCommand(cmd string) (*Repository, error) { + args, err := shellquote.Split(cmd) + parser := kong.Must(&values) + ctx, err := parser.Parse(args) + switch cmd := ctx.Command(); cmd { + return NewRepository(values.Hg.Repo) + return NewRepository(values.Hg.Init.Repo) + return nil, fmt.Errorf("unknown command %s", cmd) --- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/hg/commands_test.go Wed Apr 17 18:04:18 2019 -0500
@@ -0,0 +1,31 @@
+ "github.com/stretchr/testify/assert" +func TestRepoFromCommand(t *testing.T) { + assert := assert.New(t) + {"hg serve --stdio", ""}, + {"hg -R foo serve --stdio", "foo"}, + {"hg -R foo/bar serve --stdio", "foo/bar"}, + for _, testCase := range cases { + fmt.Printf("-----\ntest: %s\n", testCase.input) + repo, err := RepoFromCommand(testCase.input) + fmt.Printf("%#v\n%s\n", err, err) + assert.Equal(repo.Path(), testCase.expected) --- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/hg/hg.go Wed Apr 17 18:04:18 2019 -0500
@@ -0,0 +1,61 @@
+type Repository struct { +func NewRepository(path string) (*Repository, error) { + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { +func (r *Repository) Path() string { +func (r *Repository) Exists() bool { +func (r *Repository) Init(mode os.FileMode) error { + return fmt.Errorf("repo %s already exists", r.path) + err := os.MkdirAll(r.path, mode) + cmd := exec.Command("hg", "init", r.path) + cmd.Env = append(os.Environ(), "HGRCPATH=/dev/null") +func (r *Repository) Serve() error { + return fmt.Errorf("repo %s does not exist", r.path) + cmd := exec.Command("hg", "-R", r.path, "serve", "--stdio") + cmd.Env = append(os.Environ(), "HGRCPATH=/dev/null") --- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/main.go Wed Apr 17 18:04:18 2019 -0500
@@ -0,0 +1,32 @@
+ "github.com/alecthomas/kong" + log "github.com/sirupsen/logrus" + "bitbucket.org/rw_grim/hgkeeper/setup" + "bitbucket.org/rw_grim/hgkeeper/ssh" + ReposPath string `flag name:"repos-path" default:"repos" help:"the directory where the repository are stored"` + Serve ssh.ServeCommand `cmd help:"run the ssh server"` + Setup setup.SetupCommand `cmd help:"inital setup for the server"` + log.SetOutput(os.Stdout) + log.SetLevel(log.DebugLevel) + log.SetFormatter(&log.TextFormatter{ + ctx := kong.Parse(&cmd) + err := ctx.Run(cmd.ReposPath) --- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/setup/command.go Wed Apr 17 18:04:18 2019 -0500
@@ -0,0 +1,73 @@
+ "bitbucket.org/rw_grim/hgkeeper/hg" +type SetupCommand struct { + AdminRepo string `flag name:"admin-repo" default:"hgkeeper" help:"the name of the admin repo to create"` +type setupFunc func(string) error + hgrc = `# this file was created by hgkeeper, do not modify +changegroup.aaab = hg update -C default > /dev/null +changegroup.aaac = hg purge --all > /dev/null +changegroup.aaad = hgkeeper refresh-auth +func (c *SetupCommand) Run(reposPath string) error { + c.adminRepoPath = filepath.Join(reposPath, c.AdminRepo) + for _, f := range funcs { +func (c *SetupCommand) createReposDir(reposPath string) error { + if _, err := os.Stat(reposPath); os.IsNotExist(err) { + err := os.MkdirAll(reposPath, 0755) +func (c *SetupCommand) createAdminRepo(reposPath string) error { + repo, err := hg.NewRepository(c.adminRepoPath) + hgrc_path := filepath.Join(c.adminRepoPath, ".hg", "hgrc") + return ioutil.WriteFile(hgrc_path, []byte(hgrc), 0644) --- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/ssh/command.go Wed Apr 17 18:04:18 2019 -0500
@@ -0,0 +1,22 @@
+type ServeCommand struct { + Addr string `flag name:"listen-addr" short:"l" help:"what address to listen on" default:":22222"` + HostKeysPath string `flag name:"host-keys-path" short:"-H" help:"the path where host keys are kept" default:"host-keys"` +func (c *ServeCommand) Run(reposPath string) error { + err := s.loadHostKeys(c.HostKeysPath) --- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/ssh/keys.go Wed Apr 17 18:04:18 2019 -0500
@@ -0,0 +1,48 @@
+ log "github.com/sirupsen/logrus" + "golang.org/x/crypto/ssh" +func (s *Server) loadHostKeys(hostKeysPath string) error { + files, err := ioutil.ReadDir(hostKeysPath) + for _, file := range files { + if file.Mode().IsRegular() { + path := filepath.Join(hostKeysPath, file.Name()) + data, err := ioutil.ReadFile(path) + log.Warnf("failed to read %s", path) + key, err := ssh.ParsePrivateKey(data) + log.Warnf("%s is not an ssh private key", path) + log.Infof("added host key from %s", path) + return errors.New("failed to find a useable host key") --- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/ssh/server.go Wed Apr 17 18:04:18 2019 -0500
@@ -0,0 +1,98 @@
+ log "github.com/sirupsen/logrus" + "golang.org/x/crypto/ssh" +func NewServer() *Server { + s.cfg = &ssh.ServerConfig{ + PublicKeyCallback: s.publicKeyCallback, +func (s *Server) Listen(addr string) error { + listener, err := net.Listen("tcp", addr) + log.Infof("listening for ssh connections on %s", addr) + tcpConn, err := s.listener.Accept() + log.Errorf("failed to accept ssh connection: %v", err) + sshConn, chans, reqs, err := ssh.NewServerConn(tcpConn, s.cfg) + log.Errorf("ssh handshake failed: %v", err) + log.Infof("new ssh connection from %s(%s)", sshConn.RemoteAddr(), sshConn.ClientVersion()) + go ssh.DiscardRequests(reqs) + go s.processSSHChannels(chans) +func (s *Server) publicKeyCallback(meta ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { +func (s *Server) processSSHChannels(chans <-chan ssh.NewChannel) { + for ch := range chans { + // we process each channel in a go routine as there's network traffic involved + go s.processSSHChannel(ch) +func (s *Server) processSSHChannel(ch ssh.NewChannel) { + if t := ch.ChannelType(); t != "session" { + ch.Reject(ssh.UnknownChannelType, fmt.Sprintf("unsupported channel type %s", t)) + conn, requests, err := ch.Accept() + log.Warnf("failed to accept connection: %v", err) + // now run through all of the requests but only handle shell requests + for req := range requests { + log.Infof("payload: %s", string(req.Payload)) + log.Warnf("unsupported request: %s", req.Type)