Add support for integrating with openssh-server
--- a/Dockerfile Fri Jun 12 01:41:06 2020 -0500
+++ b/Dockerfile Mon Nov 09 21:11:29 2020 -0600
@@ -1,4 +1,4 @@
-FROM golang:1.13-buster as build
+FROM golang:1.15-buster as build @@ -8,7 +8,7 @@
CGO_ENABLED=0 GOOS=linux go build -a -ldflags '-extldflags "-static" -s' .
+FROM debian:bullseye-slim ENV HGKEEPER_THEME_URL=https://keep.imfreedom.org/grim/hgkeeper-theme/archive/default.tar.gz
@@ -16,7 +16,7 @@
apt-get install -y --no-install-recommends \
python3 libpython3-dev python3-pip python3-setuptools build-essential \
- openssh-client libffi6 libffi-dev nvi procps wget \
+ openssh-client libffi7 libffi-dev nvi procps wget \ pip3 install --no-binary :all: mercurial hg-evolve hgwebplus && \
apt-get remove -y --purge \
@@ -45,7 +45,5 @@
COPY --from=build /root/hgkeeper /usr/local/bin/hgkeeper
CMD ["hgkeeper", "serve"]
--- a/README.md Fri Jun 12 01:41:06 2020 -0500
+++ b/README.md Mon Nov 09 21:11:29 2020 -0600
@@ -69,11 +69,30 @@
There are some additional options which you can discover via
+hgkeeper has a couple of modes of operation but `serve` is the main mode. +The `setup` command is used to bootstrap hgkeeper. It will create the +directory for the repositories as well as the hgkeeper repository. +After initial setup, please make sure to read the README.md in the hgkeeper +repository that was created, as it details how access control works. -Once the SSH host keys and the hgkeeper repository are created, you can run
-hgkeeper with `hgkeeper serve`. There are some other options that are
-available so be sure to check out `hgkeeper serve --help`.
+The `serve` command is the main mode of operation which is to provide access to +Access control is defined in the `hgkeeper` repository that is created via the +`hgkeeper setup` command. It is implemented via [casbin](https://casbin.org) +using the RBAC with deny-override model as a base. More information can be +found in the [files](setup/resources/) that are placed in the `hgkeeper` ## Running in a Container
@@ -162,27 +181,68 @@
Of course, you'll probably want to add some more users. To find out how to do
that, be sure to read the `README.md` in the `hgkeeper` administration repo.
+Once the SSH host keys and the hgkeeper repository are created, you can run +hgkeeper with `hgkeeper serve`. There are some other options that are +available so be sure to check out `hgkeeper serve --help`. -hgkeeper has a couple of modes of operation but `serve` is the main mode.
+## Running locally with an OpenSSH Server
+There are a number of steps to integrate HGKeeper with OpenSSH Server and some +of them vary across operating system. If you run into a case where these +instructions do not work for you, please reach out via the issue tracker so we +can fix the documentation. -The `setup` command is used to bootstrap hgkeeper. It will create the
-directory for the repositories as well as the hgkeeper repository.
+### Creating the hg user +When it comes to integrating with OpenSSH server you need to create a user that +will run HGKeeper during SSH connections and own the repositories on disk. This +user can be named whatever you like, but for the purposes of this documentation +we will be naming the user `hg`. -After initial setup, please make sure to read the README.md in the hgkeeper
-repository as it details how access control works.
+For most Linux distributions, you can create the `hg` user with the following +useradd --home-dir /var/lib/hg --create-home --system --shell /usr/sbin/nologin
+To get HGKeeper fully running, you will need to run `hgkeeper setup` to create +the `hgkeeper` repository as well as the initial admin user. Once this +repository is created, make sure it and it's parent directly are owned by the +`hg` user and that the `hg` user has write permission. -The `serve` command is the main mode of operation which is to provide access to
+OpenSSH server has some very specific requirements for calling applications +directly. These requirements are that the executable as well as all of the +directories leading to the executable must be owned by root and not writeable +by the group or other users. To deal with this, we will be installing HGKeeper +into `/usr/local/bin`. This directory should fulfill all of the those +requirements. So just `sudo cp hgkeeper /usr/local/bin` and make sure that +it is owned by root with a file mode of `755`. +### Configuring OpenSSH Server
+The OpenSSH configuration is actually quite easy, you just need to drop the +following snippet into `/etc/ssh/sshd_config`. Of course, if you customized +the install location or user name you'll have to adjust that in the snippet +below. Note that the value for `--repos-path` needs to be the absolute path +You may be able to use `/etc/ssh/sshd_config.d/hgkeeper.conf` but in our +testing on Debian unstable we were unable to get it working. -Access control is defined in the `hgkeeper` repository that is created via the
-`hgkeeper setup` command. It is implemented via [casbin](https://casbin.org)
-using the RBAC with deny-override model as a base. More information can be
-found in the [files](setup/resources/) that are placed in the `hgkeeper`
+ AuthorizedKeysCommand /usr/local/bin/hgkeeper --repos-path=<path to your repositories> authorized-keys %f + AuthorizedKeysCommandUser hg +Once you've created the file, you'll need to restart OpenSSH Server. This is +usually done via `service restart ssh` but may vary based on your operating --- a/access/access.go Fri Jun 12 01:41:06 2020 -0500
+++ b/access/access.go Mon Nov 09 21:11:29 2020 -0600
@@ -60,6 +60,10 @@
+func ReposPath() string { // 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.
@@ -67,15 +71,15 @@
defer accessLock.Unlock()
- if err := refreshEnforcer(adminRepoPath); err != nil {
+ if err := refreshEnforcer(); err != nil { - if err := refreshKeys(adminRepoPath); err != nil {
+ if err := refreshKeys(); err != nil { - if err := refreshHgWeb(reposPath); err != nil {
+ if err := refreshHgWeb(); err != nil { --- a/access/enforcer.go Fri Jun 12 01:41:06 2020 -0500
+++ b/access/enforcer.go Mon Nov 09 21:11:29 2020 -0600
@@ -13,12 +13,12 @@
-func refreshEnforcer(adminRepoPath string) error {
+func refreshEnforcer() error { defer enforcerLock.Unlock()
- modelFile := filepath.Join(adminRepoPath, modelFilename)
- policyFile := filepath.Join(adminRepoPath, policyFilename)
+ modelFile := filepath.Join(AdminRepoPath(), modelFilename) + policyFile := filepath.Join(AdminRepoPath(), policyFilename) log.Debugf("reading model from %q", modelFile)
log.Debugf("reading policy from %q", policyFile)
--- a/access/hgweb.go Fri Jun 12 01:41:06 2020 -0500
+++ b/access/hgweb.go Mon Nov 09 21:11:29 2020 -0600
@@ -8,7 +8,7 @@
-func refreshHgWeb(reposPath string) error {
+func refreshHgWeb() error { fp, err := os.OpenFile(hgwebConfigPath, os.O_WRONLY|os.O_TRUNC, 0644)
@@ -17,7 +17,7 @@
fmt.Fprintf(fp, "[paths]\n")
- absReposPath, err := filepath.Abs(reposPath)
+ absReposPath, err := filepath.Abs(ReposPath()) --- a/access/users.go Fri Jun 12 01:41:06 2020 -0500
+++ b/access/users.go Mon Nov 09 21:11:29 2020 -0600
@@ -17,17 +17,19 @@
+ usernames map[string]string + keys map[string]ssh.PublicKey -func refreshKeys(adminRepoPath string) error {
+func refreshKeys() error { - keys = map[string]string{}
+ keys = map[string]ssh.PublicKey{} + usernames = map[string]string{} - keysPath := filepath.Join(adminRepoPath, keysDir)
+ keysPath := filepath.Join(AdminRepoPath(), keysDir) if _, err := os.Stat(keysPath); err != nil {
@@ -73,7 +75,8 @@
fingerprint := ssh.FingerprintSHA256(pubkey)
- keys[fingerprint] = username
+ usernames[fingerprint] = username + keys[fingerprint] = pubkey @@ -86,7 +89,7 @@
- username, found := keys[fingerprint]
+ username, found := usernames[fingerprint] return "", fmt.Errorf("user not found")
@@ -97,3 +100,14 @@
func UsernameFromPubkey(pubkey ssh.PublicKey) (string, error) {
return UsernameFromFingerprint(ssh.FingerprintSHA256(pubkey))
+func PubkeyFromFingerprint(fingerprint string) (string, error) { + pubkey, found := keys[fingerprint] + return "", fmt.Errorf("PubKey not found") + str := string(ssh.MarshalAuthorizedKey(pubkey)) --- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/authorized_keys/command.go Mon Nov 09 21:11:29 2020 -0600
@@ -0,0 +1,47 @@
+ log "github.com/sirupsen/logrus" + "keep.imfreedom.org/grim/hgkeeper/access" + "keep.imfreedom.org/grim/hgkeeper/globals" + FingerPrint string `kong:"arg,help='The fingerprint of the calling user'"` + HGKeeperExec string `kong:"flag,help='The path to hgkeeper executable',default='hgkeeper'"` +func (c *Command) Run(g *globals.Globals) error { + // we're generating output for another command so we need to turn off our + log.SetLevel(log.FatalLevel) + if err := access.Setup(g.ReposPath, g.AdminRepo); err != nil { + defer access.Teardown() + pubkey, err := access.PubkeyFromFingerprint(c.FingerPrint) + username, err := access.UsernameFromFingerprint(c.FingerPrint) + fmt.Sprintf("command=\"%s once %s\"", c.HGKeeperExec, username), + fmt.Sprintf("environment=\"HGK_REPOS_PATH=%s\"", g.ReposPath), + fmt.Printf("%s %s", strings.Join(options, ","), pubkey) --- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/examples/openssh/Dockerfile Mon Nov 09 21:11:29 2020 -0600
@@ -0,0 +1,22 @@
+FROM rwgrim/hgkeeper:latest +# rwgrim/hgkeeper already creates an hg user so we don't need to create it here. +# Install openssh-server + apt-get install -y --no-install-recommends openssh-server tini && \ + rm -rf /var/lib/apt/lists +# Copy the openssh hgkeeper config into place. The include isn't working, so +# we just append our config to the sshd_config file +COPY hgkeeper.conf /etc/ssh/hgkeeper.conf +# Configure openssh-server + mkdir -p /run/sshd && \ + cat /etc/ssh/hgkeeper.conf >> /etc/ssh/sshd_config +CMD ["tini", "--", "/usr/sbin/sshd", "-D", "-e"] --- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/examples/openssh/README.md Mon Nov 09 21:11:29 2020 -0600
@@ -0,0 +1,3 @@
+This example shows how you can integrate HGKeeper with an existing OpenSSH +Server. This is a proof of concept of the instructions in the top-level --- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/examples/openssh/hgkeeper.conf Mon Nov 09 21:11:29 2020 -0600
@@ -0,0 +1,7 @@
+PermitUserEnvironment HGK_* + AuthorizedKeysCommand /usr/local/bin/hgkeeper --repos-path=/repos authorized-keys %f + AuthorizedKeysCommandUser hg --- a/hg/hg.go Fri Jun 12 01:41:06 2020 -0500
+++ b/hg/hg.go Mon Nov 09 21:11:29 2020 -0600
@@ -1,10 +1,12 @@
log "github.com/sirupsen/logrus"
@@ -56,6 +58,63 @@
+func (c *Command) ExecPiped(stdin io.Reader, stdout, stderr io.Writer) error { + if err := c.Setup(); err != nil { + pstdout, err := cmd.StdoutPipe() + pstderr, err := cmd.StderrPipe() + pstdin, err := cmd.StdinPipe() + wg := &sync.WaitGroup{} + // we wait for stderr and stdout to finish + if err := cmd.Start(); err != nil { + if _, err := io.Copy(pstdin, stdin); err != nil { + log.Errorf("Failed to read stdin: %v", err) + if _, err := io.Copy(stdout, pstdout); err != nil { + log.Errorf("Failed to write stdout: %v", err) + if _, err := io.Copy(stderr, pstderr); err != nil { + log.Errorf("Failed to write stderr: %v", err) + // wait until all output is processed func (c *Command) Cmd() *exec.Cmd {
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/hg/parser.go Mon Nov 09 21:11:29 2020 -0600
@@ -0,0 +1,36 @@
+ "github.com/alecthomas/kong" + "github.com/kballard/go-shellquote" +type CommandArguments struct { + Repo string `kong:"flag,short='R'"` + Stdio bool `kong:"flag,name='stdio'"` + Repo string `kong:"arg"` +func ParseCommandArguments(cmd string) (string, CommandArguments, error) { + values := CommandArguments{} + args, err := shellquote.Split(cmd) + parser := kong.Must(&values) + ctx, err := parser.Parse(args) + return ctx.Command(), values, nil --- a/main.go Fri Jun 12 01:41:06 2020 -0500
+++ b/main.go Mon Nov 09 21:11:29 2020 -0600
@@ -6,15 +6,20 @@
"github.com/alecthomas/kong"
log "github.com/sirupsen/logrus"
+ "keep.imfreedom.org/grim/hgkeeper/authorized_keys" "keep.imfreedom.org/grim/hgkeeper/globals"
+ "keep.imfreedom.org/grim/hgkeeper/once" "keep.imfreedom.org/grim/hgkeeper/serve"
"keep.imfreedom.org/grim/hgkeeper/setup"
- Serve serve.Command `kong:"cmd,help='run the ssh server'"`
- Setup setup.Command `kong:"cmd,help='inital setup for the server'"`
+ AuthorizedKeys authorized_keys.Command `kong:"cmd,help='output an sshd authorized keys file'"` + Once once.Command `kong:"cmd,help='run hgkeeper for one transaction. This is used when integrating with a system ssh server'"` + Serve serve.Command `kong:"cmd,help='run the ssh server'"` + Setup setup.Command `kong:"cmd,help='inital setup for the server'"` @@ -35,6 +40,8 @@
- err := ctx.Run(&cmd.Globals)
+ if err := ctx.Run(&cmd.Globals); err != nil { --- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/once/command.go Mon Nov 09 21:11:29 2020 -0600
@@ -0,0 +1,71 @@
+ "keep.imfreedom.org/grim/hgkeeper/access" + "keep.imfreedom.org/grim/hgkeeper/globals" + "keep.imfreedom.org/grim/hgkeeper/hg" + User string `kong:"arg,help='The username who is trying to access the repositories'"` +func (c *Command) serve(reposPath, repoPath string) error { + if !access.CanRead(c.User, "/"+repoPath) { + return fmt.Errorf("repository %q not found", repoPath) + writeable := access.CanWrite(c.User, "/"+repoPath) + hgcmd := hg.Serve(filepath.Join(reposPath, repoPath), writeable) + if err := hgcmd.Setup(); err != nil { + return hgcmd.ExecPiped(os.Stdin, os.Stdout, os.Stderr) +func (c *Command) init(reposPath, repoPath string) error { + if !access.CanInit(c.User, "/"+repoPath) { + return fmt.Errorf("access denied") + hgcmd := hg.Init(filepath.Join(reposPath, repoPath)) + if err := hgcmd.Setup(); err != nil { + return hgcmd.ExecPiped(os.Stdin, os.Stdout, os.Stderr) +func (c *Command) Run(g *globals.Globals) error { + if err := access.Setup(g.ReposPath, g.AdminRepo); err != nil { + defer access.Teardown() + originalCommand, found := os.LookupEnv("SSH_ORIGINAL_COMMAND") + return fmt.Errorf("client did not specify a command") + cmd, args, err := hg.ParseCommandArguments(originalCommand) + return c.serve(g.ReposPath, args.Hg.Repo) + return c.init(g.ReposPath, args.Hg.Init.Repo) + return fmt.Errorf("unsupported command %q", cmd) --- a/ssh/commands/commands.go Fri Jun 12 01:41:06 2020 -0500
+++ b/ssh/commands/commands.go Mon Nov 09 21:11:29 2020 -0600
@@ -3,47 +3,17 @@
- "github.com/alecthomas/kong"
"github.com/gliderlabs/ssh"
- "github.com/kballard/go-shellquote"
- Repo string `kong:"flag,short='R'"`
- Stdio bool `kong:"flag,name='stdio'"`
- Repo string `kong:"arg"`
+ "keep.imfreedom.org/grim/hgkeeper/hg" Run(session ssh.Session, username string) error
-func parse(cmd string) (cli, string, error) {
- args, err := shellquote.Split(cmd)
- parser := kong.Must(&values)
- ctx, err := parser.Parse(args)
- return values, ctx.Command(), nil
func Find(cmd, reposPath string) (Command, error) {
- values, pcmd, err := parse(cmd)
+ pcmd, values, err := hg.ParseCommandArguments(cmd) --- a/ssh/commands/run.go Fri Jun 12 01:41:06 2020 -0500
+++ b/ssh/commands/run.go Mon Nov 09 21:11:29 2020 -0600
@@ -1,69 +1,11 @@
"github.com/gliderlabs/ssh"
- log "github.com/sirupsen/logrus"
"keep.imfreedom.org/grim/hgkeeper/hg"
func run(hgCmd *hg.Command, session ssh.Session) error {
- if err := hgCmd.Setup(); err != nil {
- stdout, err := cmd.StdoutPipe()
- stderr, err := cmd.StderrPipe()
- stdin, err := cmd.StdinPipe()
- wg := &sync.WaitGroup{}
- // we wait for stderr and stdout to finish
- if err := cmd.Start(); err != nil {
- if _, err := io.Copy(stdin, session); err != nil {
- log.Errorf("Failed to read stdin from session: %v", err)
- if _, err := io.Copy(session, stdout); err != nil {
- log.Errorf("Failed to write stdout to session: %v", err)
- if _, err := io.Copy(session.Stderr(), stderr); err != nil {
- log.Errorf("Failed to write stderr to session: %v", err)
- // wait until all output is processed
+ return hgCmd.ExecPiped(session, session, session.Stderr())