DIY Multi-User Git Deploys
Published 2019-5-29We 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 special
- 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
(forgit push
)git-upload-pack
(forgit 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
Did I make your day?
Buy me a coffee
(you can learn about the bigger picture I'm working towards on my patreon page )