We want git pushes and pulls to work:

git remote add live ssh://git@example.com/path/project.git git push live master Everything up-to-date

And we want ssh access to be restricted:

ssh git@example.com You've successfully authenticated, but this shell is restricted for git use only.

Beyond that, we also want to use update, pre-receive and post-receive hooks, and to understand how the whole thing works.

The Easy Parts

This is basically a recap of Digital Ocean's How to Set Up Automatic Delpoyment with Git with a VPS, but with a few special modifications in concern to multi-user git deploys.

Before continuing you will need to be logged into the server you intend to use (or simply a test server). If you're running linux on your computer, you can simply use localhost for the time being.

Create a shared user

The key to multi-user git deploys is to create a single shared user with multiple ssh keys (restricted to git commands only).

Although technically you could have multiple users and have a shared group... it's more complicated and probably not worth it, generally.

Therefore, we want to create a user that

  • has a shell (DO NOT use --shell /bin/false)
  • can only login wish key authentication (--disabled-password)
  • has a special home directory (--home /srv/www-repos)
    • has a special ~/.ssh/authorized_keys
  • has a directory for repos (we'll reuse /srv/www-repos)
  • has a directory for live sites (we'll use /srv/www)
  • doesn't copy home directory "skeleton" template files (--no-create-home)
sudo adduser --disabled-password --gecos "Git Site Deploys" --home /srv/www-repos --no-create-home  www-repos
sudo mkdir -p /srv/www-repos/.ssh /srv/www
sudo chmod 0700 /srv/www-repos/.ssh
sudo install -m 0600 /dev/null /srv/www-repos/.ssh/authorized_keys
sudo chown -R www-repos:www-repos /srv/www-repos /srv/www

Create some project repos

We need a sample project to begin pushing to to prove that our setup works:

my_site=example.com
sudo git init --bare --shared=group /srv/www-repos/${my_site}.git
sudo bash -c "cat << EOF > /srv/www-repos/${my_site}.git/hooks/post-receive
#!/usr/bin/env bash
mkdir -p /srv/www/${my_site}
git --work-tree=/srv/www/${my_site} --git-dir=/srv/www-repos/${my_site}.git checkout -f
EOF
"
sudo chmod a+x /srv/www-repos/${my_site}.git/hooks/post-receive
sudo chown -R www-repos:www-repos /srv/www-repos

The --shared=group is not necessary, but useful if you do decide to try a shared group strategy.

The post-receive hook will run any time that any branch is updated, but it will always operate from master (as written at least).

Authorize multiple user's keys

Ultimately, we want to restrict shell access to only git commands - people will technically be logging in as www-repo for the sole purpose of pushing (or pulling) git commits.

However, to get things started we'll just add some keys to the www-repos user's ~/.ssh/authorized_keys.

Locally you should get (cat ~/.ssh/id_rsa.pub) or create (ssh-keygen) ssh keys from the people that you want wto have write access.

sudo vim /srv/www-repos/.ssh/authorized_keys

Note: We created and set the permissions of authorized_keys in a previous step.

The general format of each line should be:

command="eval $SSH_ORIGINAL_COMMAND",no-port-forwarding,no-x11-forwarding,no-agent-forwarding KEY COMMENT

For example:

/srv/www-repos/.ssh/authorized_keys:

command="eval $SSH_ORIGINAL_COMMAND",no-port-forwarding,no-x11-forwarding,no-agent-forwarding,no-pty ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCzd0wHHwY5/Vj7ehhRscND18/Ha1tDUwXH5RRnQhiLXS06PyRRMpqVwWB9s/IgUiWTPWWizsUAXbQ1/4umthVi/N3y3TopGOuRCkUnYO3k+tMUokxEVRzplL616dIBUyeMIhm4W7vnz6XzYPY+go4UeD01p6b3LZf7NbtbCF4N+E9/FjLfm/gJHL2sfXjjRA6OTmleS+LspGaSnfwqNTkESzAF2tJKuXsqzH20kyiv13Lk2ogr+dfiBpiLg2NolHf1ERT+sOrTRUYB+MIz2aKC8VM13uJoMah1mOcA62XMeK2jcMdNGNzmmA7RgwhXZOtsze508bCGhM/KsYVMZ9eX user@Macbook-Pro

Later we'll replace command="eval $SSH_ORIGINAL_COMMAND", which allows any command, with something more restrictive. (as is, it runs the same the same as not having the command= option at all)

The other part, no-port-forwarding,no-x11-forwarding,no-agent-forwarding,no-pty, does protect from other uses of ssh aside from executing a shell command (which could potentially tie up system resources and DoS the server).

Test

On the local machine from which the public key was taken, you'll create a project with a test file and then push it.

First, since we haven't disabled full shell access yet, we should verify that the ssh connection works:

ssh www-repos@example.com ls -lah ~/

Next we'll create and push the project:

mkdir -p ~/Sites/example.com
pushd ~/Sites/example.com
git init
echo "Hello, World" > index.html
git add index.html
git commit -m "initial commit"
git remote add live www-repos@example.com:example.com.git
git push live master

In addition to getting a successful message, you should be able to test on the remote and see that the files were deployed:

On the remote you should be able to test that the file was deployed.

ls -lah /srv/www/example.com/

Note: The key here is that the "working directory" has no git files and is deployed to the public folder whereas the "git directory" is bare of files. Neat trick.

SSH_ORIGINAL_COMMAND

If you inspect SSH_ORIGINAL_COMMAND you'll see that, for git, it'll have one of very prefixes:

  • git-receive-pack (for git push)
  • git-upload-pack (for git pull, git fetch, etc)
  • git-upload-archive (for... something)

For example:

# result of git push to origin 'www-repos@example.com:example.com.git'
echo $SSH_ORIGINAL_COMMAND
git-receive-pack 'example.com.git'

If you use the ssh:// syntax for a repository, it will refer to the absolute directory rather than the home directory, unless you use ~:

# result of git push to 'ssh://www-repos@coolaj86.com/~/example.com.git'
echo $SSH_ORIGINAL_COMMAND
git-receive-pack '~/example.com.git'

And, as it turns out, if you always use git-receive-pack (even when you're supposed to use git-upload-pack) it'll still work.

As for the data consumed by git-receive-pack & co, it's not of much consequence. Instead, the update, pre-receive and post-receive hooks receive the information that is more interesting.

Therefore, with a little bit of bash trickery to safely handle the quoted list of arguments - of which only the second (the repository path) is important - you can change the ssh command to restrict ssh access to git only:

Restrict SSH 'Command'

Now it's time to update the ssh command (aka forceCommand) that we had previously set in authorized_keys.

command="declare -a args=($SSH_ORIGINAL_COMMAND); verb=${args[0]}; arg=$(eval echo ${args[1]}); git-${verb:4} $arg",no-port-forwarding,no-x11-forwarding,no-agent-forwarding,no-pty

The git-${verb:4} ensures that only valid git- prefixed commands will be run.

Warning: This simple bash script is probably not safe for untrusted users with ssh access. You'd probably want a proper git-proxy (such as gandolf, which is written in go) if you're hosting a public service.

Our full example changes to look like this:

command="declare -a args=($SSH_ORIGINAL_COMMAND); verb=${args[0]}; arg=$(eval echo ${args[1]}); git-${verb:4} $arg",no-port-forwarding,no-x11-forwarding,no-agent-forwarding,no-pty ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCzd0wHHwY5/Vj7ehhRscND18/Ha1tDUwXH5RRnQhiLXS06PyRRMpqVwWB9s/IgUiWTPWWizsUAXbQ1/4umthVi/N3y3TopGOuRCkUnYO3k+tMUokxEVRzplL616dIBUyeMIhm4W7vnz6XzYPY+go4UeD01p6b3LZf7NbtbCF4N+E9/FjLfm/gJHL2sfXjjRA6OTmleS+LspGaSnfwqNTkESzAF2tJKuXsqzH20kyiv13Lk2ogr+dfiBpiLg2NolHf1ERT+sOrTRUYB+MIz2aKC8VM13uJoMah1mOcA62XMeK2jcMdNGNzmmA7RgwhXZOtsze508bCGhM/KsYVMZ9eX user@Macbook-Pro

More Restrictions

If we want to implement fairly simple restrictions, we can do so:

  • don't allow force push
  • only allow certain users to push (and let all others read)
  • disallow new branches
  • protected branches

Force Push Protection

Force push is most likely disabled by default. To enable or disable it, use git config in the bare .git directory:

# disallow force push
git config receive.denyNonFastForwards true
# allow force push
git config receive.denyNonFastForwards false

Per-User Write Access Limits

Let's say we want some users to have write access, and others to have read-only access. We can modify our ssh command on a per-key (per-user) basis and then create a pre-receive hook to obey our new rules:

Let's add a CAN_WRITE=true to the command that we can use later:

/srv/www-repos/.ssh/authorized_keys:

command="export CAN_WRITE=true; declare -a args=($SSH_ORIGINAL_COMMAND); verb=${args[0]}; arg=$(eval echo ${args[1]}); git-${verb:4} $arg",no-port-forwarding,no-x11-forwarding,no-agent-forwarding,no-pty ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCzd0wHHwY5/Vj7ehhRscND18/Ha1tDUwXH5RRnQhiLXS06PyRRMpqVwWB9s/IgUiWTPWWizsUAXbQ1/4umthVi/N3y3TopGOuRCkUnYO3k+tMUokxEVRzplL616dIBUyeMIhm4W7vnz6XzYPY+go4UeD01p6b3LZf7NbtbCF4N+E9/FjLfm/gJHL2sfXjjRA6OTmleS+LspGaSnfwqNTkESzAF2tJKuXsqzH20kyiv13Lk2ogr+dfiBpiLg2NolHf1ERT+sOrTRUYB+MIz2aKC8VM13uJoMah1mOcA62XMeK2jcMdNGNzmmA7RgwhXZOtsze508bCGhM/KsYVMZ9eX user@Macbook-Pro

And then we can use a non-zero exit status from a pre-receive hook whenever it isn't set:

/srv/www-repos/example.com.git/hooks/pre-receive:

#!/usr/bin/env bash
if [ -z "${CAN_WRITE}" ]; then
  exit 400
fi

Important: If your hooks are not executable they will be silently ignored. Remember to se them as such:

sudo chmod -R a+x /srv/www-repos/example.com.git/hooks/

Disallow Creating / Deleting Branches

The update hook runs once per branch, after pre-receive. It has 3 arguments - the ref name, old rev, and new rev.

New branches have an "old rev" of 0000000000000000000000000000000000000000, which makes it easy to check for and deny:

/srv/www-repos/example.com.git/hooks/update:

#!/usr/bin/env bash
rev_old=$2
rev_new=$3
# Disallow new branch
if [ "0000000000000000000000000000000000000000" == "$rev_old" ]; then
  exit 400
fi
# Disallow remove branch
if [ "0000000000000000000000000000000000000000" == "$rev_new" ]; then
  exit 400
fi

Important: If your hooks are not executable they will be silently ignored. Remember to se them as such:

sudo chmod -R a+x /srv/www-repos/example.com.git/hooks/

Protect Branches

With liberal use of environment variables in the ssh command - or by providing a git-proxy that runs the git-* commands and is called in various hooks - it's possible to create really complex rules.

As an overly simple example of declaring that only a super-user can commit to master and some users can be restricted to only certain branches, we can do this:

/srv/www-repos/.ssh/authorized_keys:

command="export IS_ADMIN=true; declare -a args=($SSH_ORIGINAL_COMMAND); verb=${args[0]}; arg=$(eval echo ${args[1]}); git-${verb:4} $arg",no-port-forwarding,no-x11-forwarding,no-agent-forwarding,no-pty ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCzd0wHHwY5/Vj7ehhRscND18/Ha1tDUwXH5RRnQhiLXS06PyRRMpqVwWB9s/IgUiWTPWWizsUAXbQ1/4umthVi/N3y3TopGOuRCkUnYO3k+tMUokxEVRzplL616dIBUyeMIhm4W7vnz6XzYPY+go4UeD01p6b3LZf7NbtbCF4N+E9/FjLfm/gJHL2sfXjjRA6OTmleS+LspGaSnfwqNTkESzAF2tJKuXsqzH20kyiv13Lk2ogr+dfiBpiLg2NolHf1ERT+sOrTRUYB+MIz2aKC8VM13uJoMah1mOcA62XMeK2jcMdNGNzmmA7RgwhXZOtsze508bCGhM/KsYVMZ9eX user@Macbook-Pro
command="export ALLOW_BRANCH=dev; declare -a args=($SSH_ORIGINAL_COMMAND); verb=${args[0]}; arg=$(eval echo ${args[1]}); git-${verb:4} $arg",no-port-forwarding,no-x11-forwarding,no-agent-forwarding,no-pty ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAwPvDn2bwfsd5+gbTKSYnrnzYdPYRmfy+ufHM+/+7qwLVegjGjNjWXUjpcMiMlfhhpUApXCBkBh2lVmnNFKHZdnEovlYA13TH0XdiGO32pCQznjGY+VEJCGw+Ow1cgdA+nPmeJF2T713czvkyD9GK7tAny3H1V9Yh88yKE3jgVdS2x+WOsWnEFAq015XIMmrprI1tR9aUJNPfARRDP5NrmS2mrou8Kol4Oo3qx1vyUlRNgUFIg9pLIXUqqLbSxVZ14VOigsTnB7MB0zI/aOVhR7iW7DPDYGWi4Byv7G4TLdcQO1OWQwnmf0qpS47b5IOoYSF3UR7NvK98kOVbOUCQlw== other@ubuntu.local

And then we can use the variables like so:

/srv/www-repos/example.com.git/hooks/update:

#!/usr/bin/env bash
ref_name=$1
rev_old=$2
rev_new=$3

# allow super users to everything
if [ -z "$IS_ADMIN" ]; then

  # restrict users that only have one branch
  if [ -n "$ALLOW_BRANCH" ] && [ "refs/heads/$ALLOW_BRANCH" != "$ref_name" ]; then
    exit 400
  fi

  # restrict all other users from pushing to master
  if [ "refs/heads/master" == "$ref_name" ]; then
    exit 400
  fi

fi

# maybe other conditions...

Other things of interest

If you want to be able to check contents of file to make sure that passwords and such aren't committed you can use the update hook similarly to how you would use the pre-commit hook locally.

Here's an example of that: https://github.com/dhoer/gitlab-secrets/blob/master/git-hooks/git-secrets.sh#L26


By AJ ONeal

If you loved this and want more like it, sign up!


Did I make your day?
Buy me a coffeeBuy me a coffee  

(you can learn about the bigger picture I'm working towards on my patreon page )