Intro

Have you used Gogs? It’s great. Gogs is a Git service, much like GitHub and GitLab, but written in Go. It’s a immensely lighter than GitLab and it’s not lacking at all in features.

For many reasons, you may be using Docker to run Gogs. In my case, it’s because it hasn’t yet been added to Debian’s archives. In your case, maybe it’s because you need certain version, or because your first and last names start with a D and you must use Docker. You know who you are ;)

One problem I have with using Gogs in Docker right now is that I’m forced to choose between handling my real port 22 to Gogs’ own SSH server, or set it up to listen for SSH connections on a different port. I don’t like either choice.

With the first choice, I lose access to my real system through SSH, at least on the standard port. With the second choice I get ugly URLs for my repos. I don’t want them to look like this:

git://git@myserver.local:10022/username/project.git     # ugh! ugly

I want them to be like:

git@myserver.local:username/project.git                 # ah! pretty

TLDR

If you don’t want to read the whole thing, here are the steps:

  • Create a git user in your real system
  • Give it uid 1000 and gid 1000; or create a modified passwd file, identical to that inside the Gogs image, but with the UID and GID of your real git user, and mount it over /etc/passwd when running the container.
  • Run the container with -v ~git/gogs:/data -p 127.0.0.1:10022:22 -p 3000:3000. The loopback IP is for increased security.
  • Symlink ~/.ssh to gogs/git/.ssh. If you choose to map the volume /data to some other place in your system, make sure its parent directory is owned by git. Otherwise SSH won’t accept the keys.
  • Generate an ssh key pair for your real git user.
  • Run the follwing as root in the real system. This is where most of the magic happens ;)

    mkdir -p /app/gogs/
    cat >/app/gogs/gogs <<'END'
    #!/bin/bash
    GIT_KEY_ID=$(cat /home/git/.ssh/id_rsa.pub | awk '{ print $3 }')
    if ! grep -q "no-port.*$GIT_KEY_ID" /home/git/.ssh/authorized_keys
    then
        echo \
        "no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty" \
        "$(cat /home/git/.ssh/id_rsa.pub)" \
        >> /home/git/.ssh/authorized_keys
    fi
    ssh -p 10022 -o StrictHostKeyChecking=no git@127.0.0.1 \
        SSH_ORIGINAL_COMMAND=$(printf '%q' "$SSH_ORIGINAL_COMMAND") "$0" "$@"
    END
    chmod 755 /app/gogs/gogs

  • Sit back, relax and git clone, push and pull all day long ;)

How does it work

Now, let’s break down the solution. On a normal run of Gogs with Docker, you would do something like this:

docker run --rm --name=gogs -v /some/real/dir:/data \
-p 10022:22 -p 13000:3000 gogs/gogs

And in the Gogs first-time run installation page, you adjusted both the HTTP Port and SSH Port settings to point to the mapped ports of your container. The second setting is exactly what I don’t want to do.

I want to be able to do:

git clone git@myserver.local:oneuser/project.git

as well as:

ssh otheruser@myserver.local

For that to work, it’ll have to be the SSH server of my real system that replies on both cases. There’s no magic way to automatically get the user git to connect to one port and every other user to connect to port 22.

We must have a real git user

So, we must have a git user at the real system, and when that user connects through SSH, we must take whatever she was trying to run and run it as the git user inside the Gogs container.

Also, if we want Gogs’ user and SSH keys management to work, we must connect the ~/.ssh/authorized_keys of the real git user and the Gogs git user.

You may have noticed that you can create many users inside Gogs, and all those users will SSH to the Gogs server with the same git account. Gogs’ accomplishes this with several parameters before each line in ~/.ssh/authorized_keys. They look like this:

command="/app/gogs/gogs serv key-1 --config='/data/gogs/conf/app.ini'",no-po
rt-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-rsa AAAVFUEV0
SpbdpMBMc.................0ALtpNr6Nc6 gogsuser1@host1
...
command="/app/gogs/gogs serv key-2 --config='/data/gogs/conf/app.ini'",no-po
rt-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-rsa AAAAA34ff
30rV0ay6Q.................hGWhpsqNeuE gogsuser2@host2

Every time a Gogs user adds a SSH key, Gogs adds a line like that to the authorized_keys file. And the command option at the beginning of the line forces /app/gogs/gogs... to be run when that key is used to log in. The second parameter to that command (key-1, key-2, etc.) is what Gogs uses to distinguish one user from the other to, for instance, keep Alice from changing Bob’s repos. For SSH, it’s always git who is logging in.

The real and virtual git users will share authorized_keys

We must have this file serve as the authorized_keys of the real git user as well. Otherwise, the SSH keys that the Gogs users add through the web app would not work for the real system.

This is easy to do, we’ll just symlink the real user’s ~/.ssh directory to the one that will be inside the container. For simplicity, I’ll create a gogs directory under /home/git and use that as the /data volume on the container.

# run as the git user
mkdir ~/gogs
ln -s gogs/git/.ssh ~/.ssh

If, for any reason, you want to put the directory for /data somewhere else, the parent of that place must be owned by git. Otherwise, SSH will refuse to use any key it finds there.

The UID of the git must be the same inside and outside the container. The reason is that when the gogs/gogs image starts, it chown’s everything under the /data volume to the git user, including the .ssh directory. And the real system SSH server will complain if that directory doesn’t belong to the real git user.

To solve that we can either have the real git user have UID 1000, which is what is used inside the container. Or something more flexible, we can prepare a passwd file based on the one inside the image, but with the UID and GID of the git user changed to those of the real one. Then we would mount that passwd file using -v /my/crafted/passwd:/etc/passwd when running the container.

That forced command doesn’t exist

The next problem is that when a user tries to SSH as git to the real system, the authorized_keys file will force the command /app/gogs/gogs... to be run on the real system, and that doesn’t exist.

We’ll create it then. In /app/gogs/gogs we put this:

#!/bin/bash
GIT_KEY_ID=$(cat /home/git/.ssh/id_rsa.pub | awk '{ print $3 }')
if ! grep -q "no-port.*$GIT_KEY_ID" /home/git/.ssh/authorized_keys
then
    echo \
    "no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty" \
    "$(cat /home/git/.ssh/id_rsa.pub)" \
    >> /home/git/.ssh/authorized_keys
fi
ssh -p 10022 -o StrictHostKeyChecking=no git@127.0.0.1 \
    SSH_ORIGINAL_COMMAND=$(printf '%q' "$SSH_ORIGINAL_COMMAND") "$0" "$@"

When you run ssh user@host command (as git tools do), the given command is run as the given user on host. If –as is the case with Gogs–, that user’s authorized_keys have a forced command, that gets run instead, but the original command that the user was trying to run is saved in the environment variable SSH_ORIGINAL_COMMAND. That’s how Gogs knows what git was trying to do.

What we do here is run ssh again, now to log in as git to the container. That’s the -p 10022 part. We disable strict host key checking, to avoid the host authenticity question. And we’ll run the same /app/gogs/gogs serv key... that got us here inside the container. That’s what $0 $@ does. To finish we must send SSH_ORIGINAL_COMMAND with its current value into the container. That part I’m sure you already found ;)

To be able to run that second ssh, the real git user must have a key in the authorized keys file of the virtual git user –which is also its own–. So, we just need to make sure to generate a key pair with ssh-keygen for it, and our fake /app/gogs/gogs script will make sure to add it to the authorized_keys file so the ssh into the container succeeds.

The reason we do this every time is because Gogs deletes any non-gogs keys when recreating the authorized_keys file (for instance, when keys are removed in the UI).

Now go try your Gogs. At this point, it should all work transparently.

May, 2017 update

Thanks @pjeby for poiting out an escaping problem in the ssh command above, and for his notes about SELinux. Check his comments below.

Thanks @peterhalverson for pointing out that Gogs deletes non-gogs keys from the authorized_keys file when SSH Keys are removed in the web UI.