The Erlang SSH daemon behind Shrugs
Shrugs is an Apache licensed self hosted git server written in Erlang, with a light sprinkling of Rust implementing the Port talking to git using grimsby. Erlang has built in support for the SSH protocol as part of OTP. This article walks through using ssh:daemon to implement the self hosted git server in shrugs.
Introduction
Shrugs is available as a from
scratch Docker container for both amd64
and
arm64
architectures built using the BEAM Docker GitHub Release
Action. The resulting container weighs in at just 42MB
including the BEAM, working well with smaller
devices like a Raspberry PI.
The container is hosted on the free GitHub Container Registry (ghcr.io). Assuming that you already have docker installed, you can run shrugs with:
docker run \
--name shrugs \
-d \
-p 22022:22 \
ghcr.io/shortishly/shrugs
The above command maps the ssh daemon running within the shrugs container to port 22022 on the host in this example.
Either copy your authorized_keys
into the container for authentication
using docker cp:
docker cp ~/.ssh/authorized_keys shrugs:/users
Or copy the public key of individual users:
docker cp bob.pub shrugs:/users
docker cp alice.pub shrugs:/users
New keys (“*.pub” or “authorized_keys”) in the users directory will be picked up every 5 seconds.
Now you can push a repository into shrugs with:
mkdir demo
cd demo
git init .
echo "world" > hello.txt
git add hello.txt
git commit --message='demo'
git branch -M main
git remote add origin ssh://localhost:22022/demo
git push --set-upstream origin main
And then clone the demo
repository:
git clone ssh://localhost:22022/demo abc
More details about using Shrugs, A self hosted git SSH server, by following the link.
Architecture
Writing The Application
The Erlang SSH daemon is an application following the OTP design principles. In practical terms that means we need to add it as a dependency to our project, customising and starting the service to fit our needs.
Dependencies
When using erlang.mk, add a couple of lines your
Makefile
before running make deps
:
LOCAL_DEPS = \
crypto \
ssh
Alternatively, with rebar add crypto, ssh
to the applications
section of your .app.src
.
Daemon
With the dependencies added to our project, the SSH daemon can be
started. The call to shrugs_config:port(sshd)
returns the TCP port
on which the daemon should accept connections. The options allow for
full customisation of the daemon into a git server:
ssh:daemon(shrugs_config:port(sshd),
[{inet, inet},
{subsystems, []},
{key_cb, {shrugs_key_store, [KeyStore]}},
{ssh_cli, {shrugs_cli, []}},
{auth_methods, "publickey"}])
The options are:
{inet, inet}
, not using IPV6; and{subsystems, []}
, which disables the SFTP service that normally runs by default (which we don’t need for git); andkey_cb
, which usesshrugs_key_store
for SSH key management; andssh_cli
, which calls intoshrugs_cli
for the git service customisation; andauth_methods
, using justpublic_key
rather than the default which also allowed a password.
In order to use shrugs we need to be authenticated with a public key, so lets look at the key store first.
shrugs_key_store
The responsibilities of the key store implementation are:
- provide a secure repository of the service (private) host keys; and (public) authorised user keys accessible through the SSH server key API behaviour, implemented using a private ETS table; and
- the creation any host keys for the service if they are not already present with public key:generate key/1;
For is auth key/3 we
can establish whether the supplied PublicUserKey
is one authorised
with ets:member, where KeyStore
is the
private ETS key
store:
is_auth_key(PublicUserKey, User, _DaemonOptions) ->
gen_statem:call(
?MODULE,
{?FUNCTION_NAME, PublicUserKey, User}).
...
handle_event({call, From},
{is_auth_key, Key, _},
_,
#{key_store := KeyStore}) ->
{keep_state_and_data,
{reply,
From,
ets:member(KeyStore, {auth_key, Key})}}.
Similarly for host key/3, an ets:lookup returns the appropriate host key, otherwise an error is returned:
host_key(Algorithm, _DaemonOptions) ->
gen_statem:call(
?MODULE,
{?FUNCTION_NAME, Algorithm}).
handle_event({call, From},
{host_key, KeyType},
_,
#{key_store := KeyStore}) ->
{keep_state_and_data,
{reply,
From,
case ets:lookup(
KeyStore,
{host_key, key_type(KeyType)}) of
[] ->
{error, no_such_key};
[{_, PrivateKey}] ->
{ok, PrivateKey}
end}}.
While ssh_file provides a mechanism to decode
the host keys with RFC4716, the functionality to encode
keys isn’t present. Shrugs will create a host key if
one isn’t already present, persisting it in a term file rather than in
the standard RFC4716 format (by default in
shrugs_host_keys.terms
).
shrugs_users
The role of the shrugs_users module is to
watch the /users
directory adding new keys (matching “*.pub” or
“authorized_keys”) to the key store.
shrugs_cli
ssh server channel is the
behaviour for a ssh service to
implement. shrugs cli, implements the
init/1
, handle_msg/2
, handle_ssh_msg/2
and terminate/2
functions as part of this behaviour. In addition, shrugs
cli also implements callbacks from
grimsby to respond to data from git (running as
an Erlang Port with grimsby) which are
forwarded back over the ssh channel.
A git clone
, git pull
or git push
results in an exec
message
being received by the ssh connection for the git-receive-pack
,
git-upload-archive
or git-upload-pack
executable.
handle_ssh_msg(
{ssh_cm,
Connection,
{exec, Channel, WantReply, Command} = CM},
#{channels := Channels,
children := Children,
envs := Envs} = State) ->
case string:split(Command, " ") of
[Executable, QuotedRepo]
when Executable == "git-receive-pack";
Executable == "git-upload-archive";
Executable == "git-upload-pack" ->
ok = prepare_repo_dir(
Executable,
unquote(QuotedRepo)),
CoCh = {Connection, Channel},
{ok, Child} = grimsby_port:run(
#{arg0 => Executable,
args => [unquote(QuotedRepo)],
executable => filename:join(
shrugs_config:dir(bin),
"git"),
cd => shrugs_config:dir(repo),
envs => maps:get(CoCh, Envs)}),
ssh_connection:reply_request(
Connection,
WantReply,
success,
Channel),
{ok,
State#{children := Children#{Child => CoCh},
channels := Channels#{CoCh => Child}}};
To handle the operation an Erlang Port is used
to spawn git
using grimsby_port
the internals of which are
discussed in more detail here.
The Child
process created by grimsby_port
is mapped to the SSH
Connection
and Channel
pair (CoCh
), and the CoCh
is also
mapped to the Child
, so that data can be forwarded between SSH and
the Port and vise versa.
The prepare_repo_dir
function calls git init --bare
for
git-receive-pack
where the repository does not already exist in
shrugs. This means that you can push new repositories
into shrugs without any ceremony.
Data from the SSH channel is forwarded to the git process using
grimsby_port:send/2
:
handle_ssh_msg({ssh_cm,
Connection,
{data, Channel, _Type, Data} = CM},
#{channels := Channels} = State)
when is_map_key({Connection, Channel}, Channels) ->
#{{Connection, Channel} := Child} = Channels,
grimsby_port:send(Child, Data),
{ok, State};
Similarly, data from the git process is forwarded to the relevant SSH connection and channel:
handle_call(
{grimsby_port, {Stream, Child, Data} = Msg},
_From,
#{children := Children} = State)
when Stream == stdout;
Stream == stderr,
is_map_key(Child, Children) ->
#{Child := {Connection, Channel}} = Children,
case ssh_connection:send(Connection,
Channel,
data_type(Stream),
Data) of
ok ->
{reply, ok, State};
{error, Reason} ->
{reply, ok, State}
end;
When end of file is received by the SSH connection it is also forwarded to the port:
handle_ssh_msg({ssh_cm, Connection, {eof, Channel} = CM},
#{channels := Channels} = State)
when is_map_key({Connection, Channel}, Channels) ->
#{{Connection, Channel} := Child} = Channels,
grimsby_port:close(Child, stdin),
{ok, State};
That concludes the overview of the code used to implement the SSH interface to shrugs an Apache licensed self hosted git server written in Erlang, with a light sprinkling of Rust implementing the Port talking to git using grimsby.