grim/hgkeeper

Add hgweb support
feature/hgweb
2019-12-17, Gary Kramlich
8d3f0cd8203a
Parents 45b57bfc4382
Children c564dea8d263
Add hgweb support
--- a/access/access.go Tue Sep 17 20:31:09 2019 -0500
+++ b/access/access.go Tue Dec 17 23:25:25 2019 -0600
@@ -1,6 +1,8 @@
package access
import (
+ "io/ioutil"
+ "os"
"path/filepath"
"sync"
@@ -15,17 +17,37 @@
var (
accessLock sync.Mutex
+ reposPath string
adminRepoPath string
adminRepoName string
+
+ hgwebConfigPath string
)
-func Setup(reposPath, adminRepo string) error {
+func Setup(repositoriesPath, adminRepo string) error {
+ reposPath = repositoriesPath
adminRepoName = adminRepo
adminRepoPath = filepath.Join(reposPath, adminRepo)
+ configPath, err := ioutil.TempFile("", "hgkeeper-hgweb-*.config")
+ if err != nil {
+ return err
+ }
+ configPath.Close()
+ hgwebConfigPath = configPath.Name()
+
return Refresh()
}
+func Teardown() {
+ if err := os.Remove(hgwebConfigPath); err != nil {
+ log.Warnf(
+ "failed to remove temporary hgweb config from %q",
+ hgwebConfigPath,
+ )
+ }
+}
+
func AdminRepo() string {
return adminRepoName
}
@@ -34,6 +56,10 @@
return adminRepoPath
}
+func HgwebConfigPath() string {
+ return hgwebConfigPath
+}
+
// Refresh will try to reload the casbin model and policies followed by SSH
// keys. If there is an error it's possible that the casbin model and polcies
// could have been updated but the ssh keys were not.
@@ -49,6 +75,10 @@
return err
}
+ if err := refreshHgWeb(reposPath); err != nil {
+ return err
+ }
+
return nil
}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/access/hgweb.go Tue Dec 17 23:25:25 2019 -0600
@@ -0,0 +1,54 @@
+package access
+
+import (
+ "fmt"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+)
+
+func refreshHgWeb(reposPath string) error {
+ fp, err := os.OpenFile(hgwebConfigPath, os.O_WRONLY|os.O_TRUNC, 0644)
+ if err != nil {
+ return err
+ }
+ defer fp.Close()
+
+ fmt.Fprintf(fp, "[paths]\n")
+
+ absReposPath, err := filepath.Abs(reposPath)
+ if err != nil {
+ return err
+ }
+
+ // walk the reposPath, looking for .hg directories, when one is found,
+ // check if it is publicly readable, and if so, add it to the config file.
+ filepath.Walk(absReposPath, func(filename string, info os.FileInfo, err error) error {
+ // check if we're looking at a directory
+ if !info.IsDir() {
+ return nil
+ }
+
+ // check if it is a .hg directory
+ if !strings.HasSuffix(info.Name(), ".hg") {
+ return nil
+ }
+
+ // figure out the repo path that we will be checking against the casbin
+ // stuff. That means it needs to be the exact path of the repo and not
+ // include the parent directory.
+ repoPath := path.Dir(filename)
+ relativeRepoPath := strings.TrimPrefix(repoPath, absReposPath)
+
+ // check if it's publicly readable
+ if CanRead("public", relativeRepoPath) {
+ name := path.Base(repoPath)
+ fmt.Fprintf(fp, "%s = %s\n", name, repoPath)
+ }
+
+ return filepath.SkipDir
+ })
+
+ return nil
+}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/hgweb/files.go Tue Dec 17 23:25:25 2019 -0600
@@ -0,0 +1,244 @@
+// Code generated by "esc -o files.go -pkg hgweb -include files\/.+ -prefix files/ ."; DO NOT EDIT.
+
+package hgweb
+
+import (
+ "bytes"
+ "compress/gzip"
+ "encoding/base64"
+ "fmt"
+ "io"
+ "io/ioutil"
+ "net/http"
+ "os"
+ "path"
+ "sync"
+ "time"
+)
+
+type _escLocalFS struct{}
+
+var _escLocal _escLocalFS
+
+type _escStaticFS struct{}
+
+var _escStatic _escStaticFS
+
+type _escDirectory struct {
+ fs http.FileSystem
+ name string
+}
+
+type _escFile struct {
+ compressed string
+ size int64
+ modtime int64
+ local string
+ isDir bool
+
+ once sync.Once
+ data []byte
+ name string
+}
+
+func (_escLocalFS) Open(name string) (http.File, error) {
+ f, present := _escData[path.Clean(name)]
+ if !present {
+ return nil, os.ErrNotExist
+ }
+ return os.Open(f.local)
+}
+
+func (_escStaticFS) prepare(name string) (*_escFile, error) {
+ f, present := _escData[path.Clean(name)]
+ if !present {
+ return nil, os.ErrNotExist
+ }
+ var err error
+ f.once.Do(func() {
+ f.name = path.Base(name)
+ if f.size == 0 {
+ return
+ }
+ var gr *gzip.Reader
+ b64 := base64.NewDecoder(base64.StdEncoding, bytes.NewBufferString(f.compressed))
+ gr, err = gzip.NewReader(b64)
+ if err != nil {
+ return
+ }
+ f.data, err = ioutil.ReadAll(gr)
+ })
+ if err != nil {
+ return nil, err
+ }
+ return f, nil
+}
+
+func (fs _escStaticFS) Open(name string) (http.File, error) {
+ f, err := fs.prepare(name)
+ if err != nil {
+ return nil, err
+ }
+ return f.File()
+}
+
+func (dir _escDirectory) Open(name string) (http.File, error) {
+ return dir.fs.Open(dir.name + name)
+}
+
+func (f *_escFile) File() (http.File, error) {
+ type httpFile struct {
+ *bytes.Reader
+ *_escFile
+ }
+ return &httpFile{
+ Reader: bytes.NewReader(f.data),
+ _escFile: f,
+ }, nil
+}
+
+func (f *_escFile) Close() error {
+ return nil
+}
+
+func (f *_escFile) Readdir(count int) ([]os.FileInfo, error) {
+ if !f.isDir {
+ return nil, fmt.Errorf(" escFile.Readdir: '%s' is not directory", f.name)
+ }
+
+ fis, ok := _escDirs[f.local]
+ if !ok {
+ return nil, fmt.Errorf(" escFile.Readdir: '%s' is directory, but we have no info about content of this dir, local=%s", f.name, f.local)
+ }
+ limit := count
+ if count <= 0 || limit > len(fis) {
+ limit = len(fis)
+ }
+
+ if len(fis) == 0 && count > 0 {
+ return nil, io.EOF
+ }
+
+ return fis[0:limit], nil
+}
+
+func (f *_escFile) Stat() (os.FileInfo, error) {
+ return f, nil
+}
+
+func (f *_escFile) Name() string {
+ return f.name
+}
+
+func (f *_escFile) Size() int64 {
+ return f.size
+}
+
+func (f *_escFile) Mode() os.FileMode {
+ return 0
+}
+
+func (f *_escFile) ModTime() time.Time {
+ return time.Unix(f.modtime, 0)
+}
+
+func (f *_escFile) IsDir() bool {
+ return f.isDir
+}
+
+func (f *_escFile) Sys() interface{} {
+ return f
+}
+
+// FS returns a http.Filesystem for the embedded assets. If useLocal is true,
+// the filesystem's contents are instead used.
+func FS(useLocal bool) http.FileSystem {
+ if useLocal {
+ return _escLocal
+ }
+ return _escStatic
+}
+
+// Dir returns a http.Filesystem for the embedded assets on a given prefix dir.
+// If useLocal is true, the filesystem's contents are instead used.
+func Dir(useLocal bool, name string) http.FileSystem {
+ if useLocal {
+ return _escDirectory{fs: _escLocal, name: name}
+ }
+ return _escDirectory{fs: _escStatic, name: name}
+}
+
+// FSByte returns the named file from the embedded assets. If useLocal is
+// true, the filesystem's contents are instead used.
+func FSByte(useLocal bool, name string) ([]byte, error) {
+ if useLocal {
+ f, err := _escLocal.Open(name)
+ if err != nil {
+ return nil, err
+ }
+ b, err := ioutil.ReadAll(f)
+ _ = f.Close()
+ return b, err
+ }
+ f, err := _escStatic.prepare(name)
+ if err != nil {
+ return nil, err
+ }
+ return f.data, nil
+}
+
+// FSMustByte is the same as FSByte, but panics if name is not present.
+func FSMustByte(useLocal bool, name string) []byte {
+ b, err := FSByte(useLocal, name)
+ if err != nil {
+ panic(err)
+ }
+ return b
+}
+
+// FSString is the string version of FSByte.
+func FSString(useLocal bool, name string) (string, error) {
+ b, err := FSByte(useLocal, name)
+ return string(b), err
+}
+
+// FSMustString is the string version of FSMustByte.
+func FSMustString(useLocal bool, name string) string {
+ return string(FSMustByte(useLocal, name))
+}
+
+var _escData = map[string]*_escFile{
+
+ "/hgweb.cgi": {
+ name: "hgweb.cgi",
+ local: "files/hgweb.cgi",
+ size: 211,
+ modtime: 1576644347,
+ compressed: `
+H4sIAAAAAAAC/1yOwarCMBBF9/MV83iLtiDpB0g3ilY3unQpaZqmA8kkpK1FSv9dsArq8nDPgfv/lw9d
+zCviXPMNw71vPYPy3JDBApNpEofysttct+fT/ljOcwLQRO/Q6aiGSNIiueBjj7V2kusF1l8kNMvK6jT7
+KUVrRl29+yescOwMKUMAMgRLSvbkGYtlTZdfGbwkYeXAqk0/1AzgEQAA//842Xf00wAAAA==
+`,
+ },
+
+ "/": {
+ name: "/",
+ local: `.`,
+ isDir: true,
+ },
+
+ "/files": {
+ name: "files",
+ local: `files`,
+ isDir: true,
+ },
+}
+
+var _escDirs = map[string][]os.FileInfo{
+
+ ".": {},
+
+ "files": {
+ _escData["/hgweb.cgi"],
+ },
+}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/hgweb/files/hgweb.cgi Tue Dec 17 23:25:25 2019 -0600
@@ -0,0 +1,9 @@
+#!/usr/bin/env python
+config = '{{.HGWEB_CONFIG}}'
+
+from mercurial import demandimport; demandimport.enable()
+from mercurial.hgweb import hgweb, wsgicgi
+
+application = hgweb(config)
+wsgicgi.launch(application)
+
--- a/hgweb/hgweb.go Tue Sep 17 20:31:09 2019 -0500
+++ b/hgweb/hgweb.go Tue Dec 17 23:25:25 2019 -0600
@@ -1,3 +1,4 @@
+//go:generate esc -o files.go -pkg hgweb -include files\/.+ -prefix files/ .
package hgweb
import (
@@ -5,39 +6,70 @@
"net/http"
"net/http/cgi"
"os"
+ "text/template"
log "github.com/sirupsen/logrus"
+
+ "bitbucket.org/rw_grim/hgkeeper/access"
)
type Server struct {
- server *http.Server
- cgiPath string
+ listenAddr string
+ server *http.Server
+ cgiPath string
}
func NewServer(listenAddr string) (*Server, error) {
return &Server{
+ listenAddr: listenAddr,
server: &http.Server{
Addr: listenAddr,
},
}, nil
}
-func (s *Server) Startup() error {
+func (s *Server) createCGI() error {
cgiPath, err := ioutil.TempFile("", "hgkeeper-hgweb-*.cgi")
if err != nil {
return err
}
+ defer cgiPath.Close()
- log.Warnf("path: %q", cgiPath.Name())
+ // make the cgi file executable
+ if err := cgiPath.Chmod(0755); err != nil {
+ return err
+ }
+
+ // create a template based for the cgi file
+ t := template.Must(template.New("cgi").Parse(FSMustString(false, "/hgweb.cgi")))
+
+ // create our data
+ data := map[string]string{
+ "HGWEB_CONFIG": access.HgwebConfigPath(),
+ }
+
+ if err := t.Execute(cgiPath, data); err != nil {
+ return err
+ }
s.cgiPath = cgiPath.Name()
+ return nil
+}
+
+func (s *Server) Listen() error {
+ if err := s.createCGI(); err != nil {
+ return err
+ }
+
s.server.Handler = &cgi.Handler{Path: s.cgiPath}
+ log.Infof("http listening on %s", s.listenAddr)
+
return s.server.ListenAndServe()
}
-func (s *Server) Shutdown() {
+func (s *Server) Close() {
if err := os.Remove(s.cgiPath); err != nil {
log.Warnf("failed to remove temporary cgi file %q: %v", s.cgiPath, err)
}
--- a/serve/command.go Tue Sep 17 20:31:09 2019 -0500
+++ b/serve/command.go Tue Dec 17 23:25:25 2019 -0600
@@ -16,12 +16,14 @@
type Command struct {
SSHAddr string `kong:"flag,name='ssh-listen-addr',env='HGK_SSH_LISTEN_ADDR',short='l',help='what address to listen on',default=':22222'"`
SSHHostKeysPath string `kong:"flag,name='ssh-host-keys-path',env='HGK_SSH_HOST_KEYS_PATH',short='H',help='the path where host keys are kept',default='host-keys'"`
+ HTTPAddr string `kong:"flag,name='http-listen-addr',env='HGK_HTTP_LISTEN_ADDR',help='what address the http server listens on',default=':8080'"`
}
func (c *Command) Run(g *globals.Globals) error {
if err := access.Setup(g.ReposPath, g.AdminRepo); err != nil {
return err
}
+ defer access.Teardown()
signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
@@ -32,12 +34,13 @@
if err != nil {
return err
}
+ defer ssh.Close()
- hgweb, err := hgweb.NewServer(":3333")
+ hgweb, err := hgweb.NewServer(c.HTTPAddr)
if err != nil {
return err
}
- defer hgweb.Shutdown()
+ defer hgweb.Close()
go func() {
if err := ssh.Listen(c.SSHAddr); err != nil {
@@ -46,7 +49,7 @@
}()
go func() {
- if err := hgweb.Startup(); err != nil {
+ if err := hgweb.Listen(); err != nil {
errChan <- err
}
}()
--- a/setup/resources/model.conf Tue Sep 17 20:31:09 2019 -0500
+++ b/setup/resources/model.conf Tue Dec 17 23:25:25 2019 -0600
@@ -2,7 +2,7 @@
#
# This model is based on the priorty example from the casbin documentation. It
# will evaluate polcies in a top to bottom approach accepting the first one that
-# matches. This means that you have to be care when defining your policies.
+# matches. This means that you have to be careful when defining your policies.
#
# Say you would like to disable public access by default but then grant it to
# specific repositories later. This would need to be defined in the following
--- a/ssh/server.go Tue Sep 17 20:31:09 2019 -0500
+++ b/ssh/server.go Tue Dec 17 23:25:25 2019 -0600
@@ -117,3 +117,13 @@
return nil
}
+
+func (s *Server) Close() error {
+ if s.server != nil {
+ if err := s.server.Close(); err != nil {
+ s.server = nil
+ }
+ }
+
+ return nil
+}