grim/hgkeeper

initial commit, bunch of work, nothing to write home about yet
  • +4 -0
    .hgignore
  • +93 -0
    README.md
  • +25 -0
    access.yml
  • +27 -0
    access/access.go
  • +47 -0
    access/access_test.go
  • +10 -0
    go.mod
  • +25 -0
    go.sum
  • +44 -0
    hg/commands.go
  • +31 -0
    hg/commands_test.go
  • +61 -0
    hg/hg.go
  • +32 -0
    main.go
  • +73 -0
    setup/command.go
  • +22 -0
    ssh/command.go
  • +48 -0
    ssh/keys.go
  • +98 -0
    ssh/server.go
  • --- /dev/null Thu Jan 01 00:00:00 1970 +0000
    +++ b/.hgignore Wed Apr 17 18:04:18 2019 -0500
    @@ -0,0 +1,4 @@
    +syntax: regexp
    +^host-keys\/
    +^hgkeeper$
    +
    --- /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
    +
    +hgkeeper is an server for [mercurial](https://www.mercurial-scm.org/)
    +repositories. It provides access control for SSH access.
    +
    +# Status
    +
    +This project is brand new and not even functional yet... But if you're
    +interested in helping, please do!!
    +
    +# Usage
    +
    +hgkeeper has a couple modes of operation but `serve` is the main mode.
    +
    +## setup
    +
    +The `setup` command is used to bootstrap hgkeeper. It will create the
    +directory for the repositores, the hgkeeper repository, and create an initial
    +`access.yml`.
    +
    +## serve
    +
    +The `serve` command is the main mode of operation which is to provide access to
    +the repositories.
    +
    +# Access Control
    +
    +Controlling access to the repositories is done via the `hgkeeper` repository.
    +The repository has a specific layout to make it easier to reason about.
    +
    +```
    +hgkeeper/
    + - keys/
    + - user1
    + - user2
    + - access.yml
    +```
    +
    +## keys/
    +
    +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
    +
    +`access.yml` is the access control configuration. When you initial setup
    +`hgkeeper` you will get an `access.yml` like the following.
    +
    +```yaml
    +global:
    + init:
    + - admins
    + read:
    + - public
    +groups:
    + admins:
    + - grim
    +patterns:
    + hgkeeper:
    + read:
    + - admins
    + write:
    + - admins
    +```
    +
    +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
    +group.
    +
    +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
    +field.
    +
    + 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 @@
    +---
    +global:
    + init:
    + - admins
    + read:
    + - public
    +groups:
    + admins:
    + - grim
    + pidgin:
    + - grim
    + pidgin-gsoc:
    + - student1
    +patterns:
    + hgkeeper:
    + read:
    + - admins
    + pidgin/gsoc/*:
    + write:
    + - pidgin
    + - pidgin-gsoc
    + pidgin/*:
    + write:
    + - pidgin
    +
    --- /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 @@
    +package main
    +
    +import (
    + "io"
    +
    + "github.com/go-yaml/yaml"
    +)
    +
    +type acl struct {
    + 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) {
    + ac := &AccessControl{}
    +
    + err := yaml.NewDecoder(r).Decode(ac)
    +
    + return ac, err
    +}
    --- /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 @@
    +package main
    +
    +import (
    + "fmt"
    + "strings"
    + "testing"
    +
    + "github.com/stretchr/testify/assert"
    +)
    +
    +func TestAccessControlLoadSimple(t *testing.T) {
    + assert := assert.New(t)
    + data := `
    +global:
    + init:
    + - admin
    + read:
    + - everyone
    +patterns:
    + hgkeeper:
    + read:
    + - admin
    +`
    +
    + fmt.Printf("data: %#v\n", data)
    + ac, err := loadAccessControl(strings.NewReader(data))
    + assert.Nil(err)
    +
    + fmt.Printf("ac: %#v\n", ac)
    + assert.NotNil(ac)
    + assert.Equal(
    + acl{
    + Init: []string{"admin"},
    + Read: []string{"everyone"},
    + Write: nil,
    + },
    + ac.Global,
    + )
    + assert.Equal(
    + acl{
    + Init: nil,
    + Read: []string{"admin"},
    + Write: nil,
    + },
    + 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
    +
    +require (
    + 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 @@
    +package hg
    +
    +import (
    + "fmt"
    +
    + "github.com/alecthomas/kong"
    + "github.com/kballard/go-shellquote"
    +)
    +
    +type cli struct {
    + Hg struct {
    + Repo string `flag short:"R"`
    + Serve struct {
    + Stdio bool `flag name:"stdio"`
    + } `cmd`
    + Init struct {
    + Repo string `arg`
    + } `cmd`
    + } `cmd`
    +}
    +
    +func RepoFromCommand(cmd string) (*Repository, error) {
    + args, err := shellquote.Split(cmd)
    + if err != nil {
    + return nil, err
    + }
    +
    + values := cli{}
    + parser := kong.Must(&values)
    +
    + ctx, err := parser.Parse(args)
    + if err != nil {
    + return nil, err
    + }
    +
    + switch cmd := ctx.Command(); cmd {
    + case "hg serve":
    + return NewRepository(values.Hg.Repo)
    + case "hg init":
    + return NewRepository(values.Hg.Init.Repo)
    + default:
    + 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 @@
    +package hg
    +
    +import (
    + "fmt"
    + "testing"
    +
    + "github.com/stretchr/testify/assert"
    +)
    +
    +type testCase struct {
    + input string
    + expected string
    +}
    +
    +func TestRepoFromCommand(t *testing.T) {
    + assert := assert.New(t)
    +
    + cases := []testCase{
    + {"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.Nil(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 @@
    +package hg
    +
    +import (
    + "fmt"
    + "os"
    + "os/exec"
    +)
    +
    +type Repository struct {
    + path string
    + exists bool
    +}
    +
    +func NewRepository(path string) (*Repository, error) {
    + exists := true
    + if _, err := os.Stat(path); err != nil {
    + if os.IsNotExist(err) {
    + exists = false
    + } else {
    + return nil, err
    + }
    + }
    +
    + return &Repository{
    + path: path,
    + exists: exists,
    + }, nil
    +}
    +
    +func (r *Repository) Path() string {
    + return r.path
    +}
    +
    +func (r *Repository) Exists() bool {
    + return r.exists
    +}
    +
    +func (r *Repository) Init(mode os.FileMode) error {
    + if r.exists {
    + return fmt.Errorf("repo %s already exists", r.path)
    + }
    +
    + err := os.MkdirAll(r.path, mode)
    + if err != nil {
    + return err
    + }
    +
    + cmd := exec.Command("hg", "init", r.path)
    + cmd.Env = append(os.Environ(), "HGRCPATH=/dev/null")
    + return cmd.Run()
    +}
    +
    +func (r *Repository) Serve() error {
    + if !r.exists {
    + 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")
    + return cmd.Run()
    +}
    --- /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 @@
    +package main
    +
    +import (
    + "os"
    +
    + "github.com/alecthomas/kong"
    + log "github.com/sirupsen/logrus"
    +
    + "bitbucket.org/rw_grim/hgkeeper/setup"
    + "bitbucket.org/rw_grim/hgkeeper/ssh"
    +)
    +
    +type commands struct {
    + 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"`
    +}
    +
    +func init() {
    + log.SetOutput(os.Stdout)
    + log.SetLevel(log.DebugLevel)
    + log.SetFormatter(&log.TextFormatter{
    + FullTimestamp: true,
    + })
    +}
    +
    +func main() {
    + cmd := commands{}
    + ctx := kong.Parse(&cmd)
    + err := ctx.Run(cmd.ReposPath)
    + ctx.FatalIfErrorf(err)
    +}
    --- /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 @@
    +package setup
    +
    +import (
    + "io/ioutil"
    + "os"
    + "path/filepath"
    +
    + "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"`
    + adminRepoPath string
    +}
    +
    +type setupFunc func(string) error
    +
    +var (
    + hgrc = `# this file was created by hgkeeper, do not modify
    +[extensions]
    +hgext.purge =
    +
    +[hooks]
    +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)
    +
    + funcs := []setupFunc{
    + c.createReposDir,
    + c.createAdminRepo,
    + }
    +
    + for _, f := range funcs {
    + err := f(reposPath)
    + if err != nil {
    + return err
    + }
    + }
    +
    + return nil
    +}
    +
    +func (c *SetupCommand) createReposDir(reposPath string) error {
    + if _, err := os.Stat(reposPath); os.IsNotExist(err) {
    + err := os.MkdirAll(reposPath, 0755)
    + if err != nil {
    + return err
    + }
    + }
    +
    + return nil
    +}
    +
    +func (c *SetupCommand) createAdminRepo(reposPath string) error {
    + repo, err := hg.NewRepository(c.adminRepoPath)
    + if err != nil {
    + return err
    + }
    +
    + err = repo.Init(0700)
    + if err != nil {
    + return err
    + }
    +
    + 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 @@
    +package ssh
    +
    +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 {
    + s := NewServer()
    +
    + err := s.loadHostKeys(c.HostKeysPath)
    + if err != nil {
    + return err
    + }
    +
    + err = s.Listen(c.Addr)
    + if err != nil {
    + return err
    + }
    +
    + return nil
    +}
    --- /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 @@
    +package ssh
    +
    +import (
    + "errors"
    + "io/ioutil"
    + "path/filepath"
    +
    + log "github.com/sirupsen/logrus"
    + "golang.org/x/crypto/ssh"
    +)
    +
    +func (s *Server) loadHostKeys(hostKeysPath string) error {
    + files, err := ioutil.ReadDir(hostKeysPath)
    + if err != nil {
    + return err
    + }
    +
    + found := false
    +
    + for _, file := range files {
    + if file.Mode().IsRegular() {
    + path := filepath.Join(hostKeysPath, file.Name())
    +
    + data, err := ioutil.ReadFile(path)
    + if err != nil {
    + log.Warnf("failed to read %s", path)
    + continue
    + }
    +
    + key, err := ssh.ParsePrivateKey(data)
    + if err != nil {
    + log.Warnf("%s is not an ssh private key", path)
    + continue
    + }
    +
    + s.cfg.AddHostKey(key)
    + found = true
    +
    + log.Infof("added host key from %s", path)
    + }
    + }
    +
    + if !found {
    + return errors.New("failed to find a useable host key")
    + }
    +
    + return nil
    +}
    --- /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 @@
    +package ssh
    +
    +import (
    + "fmt"
    + "net"
    +
    + log "github.com/sirupsen/logrus"
    + "golang.org/x/crypto/ssh"
    +)
    +
    +type Server struct {
    + cfg *ssh.ServerConfig
    + listener net.Listener
    +}
    +
    +func NewServer() *Server {
    + s := &Server{}
    + s.cfg = &ssh.ServerConfig{
    + MaxAuthTries: 1,
    + PublicKeyCallback: s.publicKeyCallback,
    + }
    +
    + return s
    +}
    +
    +func (s *Server) Listen(addr string) error {
    + listener, err := net.Listen("tcp", addr)
    + if err != nil {
    + return err
    + }
    +
    + s.listener = listener
    +
    + log.Infof("listening for ssh connections on %s", addr)
    + for {
    + tcpConn, err := s.listener.Accept()
    + if err != nil {
    + log.Errorf("failed to accept ssh connection: %v", err)
    + continue
    + }
    +
    + sshConn, chans, reqs, err := ssh.NewServerConn(tcpConn, s.cfg)
    + if err != nil {
    + log.Errorf("ssh handshake failed: %v", err)
    + continue
    + }
    +
    + log.Infof("new ssh connection from %s(%s)", sshConn.RemoteAddr(), sshConn.ClientVersion())
    +
    + go ssh.DiscardRequests(reqs)
    +
    + go s.processSSHChannels(chans)
    + }
    +
    + return nil
    +}
    +
    +func (s *Server) publicKeyCallback(meta ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
    + return nil, nil
    +}
    +
    +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))
    + return
    + }
    +
    + conn, requests, err := ch.Accept()
    + if err != nil {
    + log.Warnf("failed to accept connection: %v", err)
    + return
    + }
    +
    + defer conn.Close()
    +
    + // now run through all of the requests but only handle shell requests
    + go func() {
    + for req := range requests {
    + switch req.Type {
    + case "shell":
    + log.Infof("payload: %s", string(req.Payload))
    + req.Reply(true, nil)
    + default:
    + log.Warnf("unsupported request: %s", req.Type)
    + if req.WantReply {
    + req.Reply(false, nil)
    + }
    + }
    + }
    + }()
    +}