grim/hgkeeper

f4875b6bffa5
Parents 7dcc72aff778
Children f24e3134ae3d
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
WORKDIR /root
@@ -8,7 +8,7 @@
go generate ./... && \
CGO_ENABLED=0 GOOS=linux go build -a -ldflags '-extldflags "-static" -s' .
-FROM debian:buster-slim
+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 update && \
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
-USER hg
-
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 setup --help`.
-## Running Locally
+## Usage
+
+hgkeeper has a couple of 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 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`.
+### serve
+
+The `serve` command is the main mode of operation which is to provide access to
+the repositories.
+
+## Access Control
+
+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`
+repository.
## 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.
-## Usage
+## Running Locally
+
+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
-### setup
+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
+command:
+
+```
+useradd --home-dir /var/lib/hg --create-home --system --shell /usr/sbin/nologin
+```
+
+### Setup HGKeeper
-### serve
+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.
+
+### Installing HGKeeper
-The `serve` command is the main mode of operation which is to provide access to
-the repositories.
+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
-## Access Control
+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
+to your repositories.
+
+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`
-repository.
+```
+Match User hg
+ 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
+system.
+
--- 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 @@
return hgwebConfigPath
}
+func ReposPath() string {
+ return reposPath
+}
+
// 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 @@
accessLock.Lock()
defer accessLock.Unlock()
- if err := refreshEnforcer(adminRepoPath); err != nil {
+ if err := refreshEnforcer(); err != nil {
return err
}
- if err := refreshKeys(adminRepoPath); err != nil {
+ if err := refreshKeys(); err != nil {
return err
}
- if err := refreshHgWeb(reposPath); err != nil {
+ if err := refreshHgWeb(); err != nil {
return err
}
--- 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 @@
enforcerLock sync.Mutex
)
-func refreshEnforcer(adminRepoPath string) error {
+func refreshEnforcer() error {
enforcerLock.Lock()
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 @@
"strings"
)
-func refreshHgWeb(reposPath string) error {
+func refreshHgWeb() error {
fp, err := os.OpenFile(hgwebConfigPath, os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return err
@@ -17,7 +17,7 @@
fmt.Fprintf(fp, "[paths]\n")
- absReposPath, err := filepath.Abs(reposPath)
+ absReposPath, err := filepath.Abs(ReposPath())
if err != nil {
return err
}
--- 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 @@
)
var (
- keys map[string]string
- keysLock sync.Mutex
+ usernames map[string]string
+ keys map[string]ssh.PublicKey
+ keysLock sync.Mutex
)
-func refreshKeys(adminRepoPath string) error {
+func refreshKeys() error {
keysLock.Lock()
defer keysLock.Unlock()
- 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 {
if os.IsNotExist(err) {
@@ -73,7 +75,8 @@
}
fingerprint := ssh.FingerprintSHA256(pubkey)
- keys[fingerprint] = username
+ usernames[fingerprint] = username
+ keys[fingerprint] = pubkey
counter++
}
@@ -86,7 +89,7 @@
keysLock.Lock()
defer keysLock.Unlock()
- username, found := keys[fingerprint]
+ username, found := usernames[fingerprint]
if !found {
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]
+ if !found {
+ return "", fmt.Errorf("PubKey not found")
+ }
+
+ str := string(ssh.MarshalAuthorizedKey(pubkey))
+
+ return str, nil
+}
--- /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 @@
+package authorized_keys
+
+import (
+ "fmt"
+ "strings"
+
+ log "github.com/sirupsen/logrus"
+
+ "keep.imfreedom.org/grim/hgkeeper/access"
+ "keep.imfreedom.org/grim/hgkeeper/globals"
+)
+
+type Command struct {
+ 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
+ // normal logging.
+ log.SetLevel(log.FatalLevel)
+
+ if err := access.Setup(g.ReposPath, g.AdminRepo); err != nil {
+ return err
+ }
+ defer access.Teardown()
+
+ pubkey, err := access.PubkeyFromFingerprint(c.FingerPrint)
+ if err != nil {
+ return err
+ }
+
+ username, err := access.UsernameFromFingerprint(c.FingerPrint)
+ if err != nil {
+ return err
+ }
+
+ options := []string{
+ fmt.Sprintf("command=\"%s once %s\"", c.HGKeeperExec, username),
+ "restrict",
+ fmt.Sprintf("environment=\"HGK_REPOS_PATH=%s\"", g.ReposPath),
+ }
+
+ fmt.Printf("%s %s", strings.Join(options, ","), pubkey)
+
+ return nil
+}
--- /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
+RUN set -ex && \
+ apt-get update && \
+ apt-get install -y --no-install-recommends openssh-server tini && \
+ apt-get clean && \
+ 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
+RUN set -ex && \
+ mkdir -p /run/sshd && \
+ cat /etc/ssh/hgkeeper.conf >> /etc/ssh/sshd_config
+
+# Set the command
+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
+README.md.
--- /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_*
+
+Match User hg
+ 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 @@
package hg
import (
+ "io"
"os"
"os/exec"
"path/filepath"
"strings"
+ "sync"
log "github.com/sirupsen/logrus"
@@ -56,6 +58,63 @@
return string(raw), err
}
+func (c *Command) ExecPiped(stdin io.Reader, stdout, stderr io.Writer) error {
+ if err := c.Setup(); err != nil {
+ return err
+ }
+ defer c.Teardown()
+
+ cmd := c.Cmd()
+ pstdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return err
+ }
+
+ pstderr, err := cmd.StderrPipe()
+ if err != nil {
+ return err
+ }
+
+ pstdin, err := cmd.StdinPipe()
+ if err != nil {
+ return err
+ }
+
+ wg := &sync.WaitGroup{}
+ // we wait for stderr and stdout to finish
+ wg.Add(2)
+
+ if err := cmd.Start(); err != nil {
+ return err
+ }
+
+ go func() {
+ defer pstdin.Close()
+ if _, err := io.Copy(pstdin, stdin); err != nil {
+ log.Errorf("Failed to read stdin: %v", err)
+ }
+ }()
+
+ go func() {
+ defer wg.Done()
+ if _, err := io.Copy(stdout, pstdout); err != nil {
+ log.Errorf("Failed to write stdout: %v", err)
+ }
+ }()
+
+ go func() {
+ defer wg.Done()
+ if _, err := io.Copy(stderr, pstderr); err != nil {
+ log.Errorf("Failed to write stderr: %v", err)
+ }
+ }()
+
+ // wait until all output is processed
+ wg.Wait()
+
+ return cmd.Wait()
+}
+
func (c *Command) Cmd() *exec.Cmd {
return c.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 @@
+package hg
+
+import (
+ "github.com/alecthomas/kong"
+ "github.com/kballard/go-shellquote"
+)
+
+type CommandArguments struct {
+ Hg struct {
+ Repo string `kong:"flag,short='R'"`
+ Serve struct {
+ Stdio bool `kong:"flag,name='stdio'"`
+ } `kong:"cmd"`
+ Init struct {
+ Repo string `kong:"arg"`
+ } `kong:"cmd"`
+ } `kong:"cmd"`
+}
+
+func ParseCommandArguments(cmd string) (string, CommandArguments, error) {
+ values := CommandArguments{}
+
+ args, err := shellquote.Split(cmd)
+ if err != nil {
+ return "", values, err
+ }
+
+ parser := kong.Must(&values)
+
+ ctx, err := parser.Parse(args)
+ if err != nil {
+ return "", values, err
+ }
+
+ 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"
)
type commands struct {
globals.Globals
- 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'"`
}
func init() {
@@ -35,6 +40,8 @@
func main() {
cmd := commands{}
ctx := kong.Parse(&cmd)
- err := ctx.Run(&cmd.Globals)
- ctx.FatalIfErrorf(err)
+
+ if err := ctx.Run(&cmd.Globals); err != nil {
+ ctx.FatalIfErrorf(err)
+ }
}
--- /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 @@
+package once
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "keep.imfreedom.org/grim/hgkeeper/access"
+ "keep.imfreedom.org/grim/hgkeeper/globals"
+ "keep.imfreedom.org/grim/hgkeeper/hg"
+)
+
+type Command struct {
+ 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 err
+ }
+ defer hgcmd.Teardown()
+
+ 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 err
+ }
+ defer hgcmd.Teardown()
+
+ 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 {
+ return err
+ }
+ defer access.Teardown()
+
+ originalCommand, found := os.LookupEnv("SSH_ORIGINAL_COMMAND")
+ if !found {
+ return fmt.Errorf("client did not specify a command")
+ }
+
+ cmd, args, err := hg.ParseCommandArguments(originalCommand)
+ if err != nil {
+ return err
+ }
+
+ switch cmd {
+ case "hg serve":
+ return c.serve(g.ReposPath, args.Hg.Repo)
+ case "hg init <repo>":
+ return c.init(g.ReposPath, args.Hg.Init.Repo)
+ default:
+ 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 @@
import (
"fmt"
- "github.com/alecthomas/kong"
"github.com/gliderlabs/ssh"
- "github.com/kballard/go-shellquote"
-)
-type cli struct {
- Hg struct {
- Repo string `kong:"flag,short='R'"`
- Serve struct {
- Stdio bool `kong:"flag,name='stdio'"`
- } `kong:"cmd"`
- Init struct {
- Repo string `kong:"arg"`
- } `kong:"cmd"`
- } `kong:"cmd"`
-}
+ "keep.imfreedom.org/grim/hgkeeper/hg"
+)
type Command interface {
Run(session ssh.Session, username string) error
}
-func parse(cmd string) (cli, string, error) {
- values := cli{}
-
- args, err := shellquote.Split(cmd)
- if err != nil {
- return values, "", err
- }
-
- parser := kong.Must(&values)
-
- ctx, err := parser.Parse(args)
- if err != nil {
- return values, "", err
- }
-
- return values, ctx.Command(), nil
-}
-
func Find(cmd, reposPath string) (Command, error) {
- values, pcmd, err := parse(cmd)
+ pcmd, values, err := hg.ParseCommandArguments(cmd)
if err != nil {
return nil, err
}
--- 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 @@
package commands
import (
- "io"
- "sync"
-
"github.com/gliderlabs/ssh"
- log "github.com/sirupsen/logrus"
"keep.imfreedom.org/grim/hgkeeper/hg"
)
func run(hgCmd *hg.Command, session ssh.Session) error {
- cmd := hgCmd.Cmd()
-
- if err := hgCmd.Setup(); err != nil {
- return err
- }
- defer hgCmd.Teardown()
-
- stdout, err := cmd.StdoutPipe()
- if err != nil {
- return err
- }
-
- stderr, err := cmd.StderrPipe()
- if err != nil {
- return err
- }
-
- stdin, err := cmd.StdinPipe()
- if err != nil {
- return err
- }
-
- wg := &sync.WaitGroup{}
- // we wait for stderr and stdout to finish
- wg.Add(2)
-
- if err := cmd.Start(); err != nil {
- return err
- }
-
- go func() {
- defer stdin.Close()
- if _, err := io.Copy(stdin, session); err != nil {
- log.Errorf("Failed to read stdin from session: %v", err)
- }
- }()
-
- go func() {
- defer wg.Done()
- if _, err := io.Copy(session, stdout); err != nil {
- log.Errorf("Failed to write stdout to session: %v", err)
- }
- }()
-
- go func() {
- defer wg.Done()
- 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
- wg.Wait()
-
- return cmd.Wait()
+ return hgCmd.ExecPiped(session, session, session.Stderr())
}