SSH git protocol
Wires git@shithub.sh:owner/repo.git clone/push to the same
authorize-and-dispatch path as the HTTPS git surface. Three pieces
must all be in place; missing any gives users a clone URL that
errors at connect:
- Code (
internal/auth/policy/...,cmd/shithubd/ssh.go):shithubd ssh-authkeys <fingerprint>returns the matching user's authorized_keys line with a forcedcommand=that runsshithubd ssh-shell <user_id>. The shell subcommand parsesSSH_ORIGINAL_COMMAND, authorizes, andsyscall.Execs intogit-{upload,receive}-pack. Already in tree (S13). - sshd (
deploy/sshd_config.j2): aMatch User gitblock wiresAuthorizedKeysCommand /usr/local/bin/shithubd ssh-authkeys %fandAuthorizedKeysCommandUser shithub-ssh. - Config (
web.env):SHITHUB_AUTH__SSH__ENABLED=trueandSHITHUB_AUTH__SSH__HOST=git@<domain>. Without this, the sshd path is reachable but the UI hides it from clone widgets.
First-time enable on an existing droplet
The clean path is ansible-playbook deploy/ansible/site.yml -l shithub-app,
which provisions everything below. SSH-git turned out to need
six pieces — the obvious ones from S13 plus five subtleties
discovered live. They are all encoded in ansible (after this PR);
this list is for operators who want to apply piece-by-piece or
debug one failure mode at a time.
| # | Piece | Lives in | Failure if missing |
|---|---|---|---|
| 1 | git system user with git-shell, in shithub group, password-cleared (passwd -d) |
roles/base/tasks/main.yml |
"User git not allowed because account is locked" |
| 2 | Match User git sshd block pointing at the AKC wrapper |
deploy/sshd_config.j2 |
sshd offers no AKC for git@; "Permission denied (publickey)" |
| 3 | /usr/local/bin/shithub-ssh-authkeys wrapper sourcing web.env then exec'ing shithubd ssh-authkeys |
roles/shithubd/files/shithub-ssh-authkeys |
AKC returns empty (no SHITHUB_DATABASE_URL in env); "Permission denied (publickey)" |
| 4 | /var/lib/git/git-shell-commands/shithubd wrapper sourcing web.env then exec'ing the bare binary |
roles/shithubd/files/git-shell-commands-shithubd |
git-shell rejects forced command; "fatal: unrecognized command" |
| 5 | /etc/shithub/web.env mode 0640 (group=shithub) so the git user can read it through the wrappers above |
roles/shithubd/tasks/main.yml |
ssh-shell: cfg: ... permission denied |
| 6 | SHITHUB_AUTH__SSH__{ENABLED,HOST} env vars on web.env |
roles/shithubd/templates/web.env.j2 |
repo pages don't show the SSH clone URL (sshd path still works) |
Verify:
# (a) repo page now shows the SSH clone URL
curl -fsS https://shithub.sh/tenseleyflow/shithub | grep -i 'ssh' | head
# (b) the git user exists with git-shell
ssh root@shithub.sh 'getent passwd git'
# git:x:117:65534::/var/lib/git:/usr/bin/git-shell
# (c) AKC handshake works for an unknown fingerprint (returns nothing,
# exits 0 — that's the fail-closed contract):
ssh root@shithub.sh '/usr/local/bin/shithubd ssh-authkeys SHA256:not-a-real-key 2>&1; echo rc=$?'
# (empty output, rc=0)
# (d) end-to-end clone with a key you've added at /settings/keys:
ssh-keygen -t ed25519 -f ~/.ssh/shithub-test -N '' -C 'shithub-ssh-test'
cat ~/.ssh/shithub-test.pub
# paste into https://shithub.sh/settings/keys
GIT_SSH_COMMAND='ssh -i ~/.ssh/shithub-test -o IdentitiesOnly=yes' \
git clone git@shithub.sh:tenseleyflow/shithub.git /tmp/shithub-ssh-clone
If (d) hangs or rejects:
journalctl -u ssh -n 50 --no-pager | grep -E "git|AKC|authkeys"journalctl -u shithubd-web -n 100 | grep ssh-shell- Manually invoke the AKC with the real fingerprint:
ssh root@shithub.sh "/usr/local/bin/shithubd ssh-authkeys SHA256:<your-fp>"Should print one line starting withcommand="...shithubd ssh-shell...".
When something breaks
| Symptom | Likely cause |
|---|---|
git clone git@shithub.sh:... hangs at "Connecting" |
sshd not listening on 22, or firewall blocking. systemctl status ssh; ufw status |
| Hangs at "Permission denied (publickey)" | Key not in user's /settings/keys, OR fingerprint mismatch. The DB stores SHA256:... from ssh-keygen -lf <key>. |
| "Permission denied" right after key offer | AKC returned an empty string (key DOES match no row). Run shithubd ssh-authkeys <your-fp> manually as root to debug. |
| Connects then "fatal: protocol error" | ssh-shell rejected the requested op. Check journalctl -u shithubd-web for the ssh-shell: denied entry. |
git push works but post-receive hooks don't fire |
SHITHUB_* env not threaded through. Check protocol.PrepareDispatch — that's the env-build site. |
Disabling without removing
Set SHITHUB_AUTH__SSH__ENABLED=false in web.env and restart
shithubd-web. The clone widget hides the SSH URL but the sshd path
still works for anyone who knows it. Use this when the SSH service
is having issues but you don't want to alarm people who try the UI.
To fully disable: comment the Match User git block in
/etc/ssh/sshd_config and reload sshd. The git user can stay.
View source
| 1 | # SSH git protocol |
| 2 | |
| 3 | Wires `git@shithub.sh:owner/repo.git` clone/push to the same |
| 4 | authorize-and-dispatch path as the HTTPS git surface. Three pieces |
| 5 | must all be in place; missing any gives users a clone URL that |
| 6 | errors at connect: |
| 7 | |
| 8 | 1. **Code** (`internal/auth/policy/...`, `cmd/shithubd/ssh.go`): |
| 9 | `shithubd ssh-authkeys <fingerprint>` returns the matching |
| 10 | user's authorized_keys line with a forced `command=` that runs |
| 11 | `shithubd ssh-shell <user_id>`. The shell subcommand parses |
| 12 | `SSH_ORIGINAL_COMMAND`, authorizes, and `syscall.Exec`s into |
| 13 | `git-{upload,receive}-pack`. Already in tree (S13). |
| 14 | 2. **sshd** (`deploy/sshd_config.j2`): a `Match User git` block |
| 15 | wires `AuthorizedKeysCommand /usr/local/bin/shithubd |
| 16 | ssh-authkeys %f` and `AuthorizedKeysCommandUser shithub-ssh`. |
| 17 | 3. **Config** (`web.env`): `SHITHUB_AUTH__SSH__ENABLED=true` and |
| 18 | `SHITHUB_AUTH__SSH__HOST=git@<domain>`. Without this, the |
| 19 | sshd path is reachable but the UI hides it from clone widgets. |
| 20 | |
| 21 | ## First-time enable on an existing droplet |
| 22 | |
| 23 | The clean path is `ansible-playbook deploy/ansible/site.yml -l shithub-app`, |
| 24 | which provisions everything below. SSH-git turned out to need |
| 25 | **six** pieces — the obvious ones from S13 plus five subtleties |
| 26 | discovered live. They are all encoded in ansible (after this PR); |
| 27 | this list is for operators who want to apply piece-by-piece or |
| 28 | debug one failure mode at a time. |
| 29 | |
| 30 | | # | Piece | Lives in | Failure if missing | |
| 31 | |---|---|---|---| |
| 32 | | 1 | `git` system user with `git-shell`, in `shithub` group, password-cleared (`passwd -d`) | `roles/base/tasks/main.yml` | "User git not allowed because account is locked" | |
| 33 | | 2 | `Match User git` sshd block pointing at the AKC wrapper | `deploy/sshd_config.j2` | sshd offers no AKC for `git@`; "Permission denied (publickey)" | |
| 34 | | 3 | `/usr/local/bin/shithub-ssh-authkeys` wrapper sourcing web.env then exec'ing `shithubd ssh-authkeys` | `roles/shithubd/files/shithub-ssh-authkeys` | AKC returns empty (no `SHITHUB_DATABASE_URL` in env); "Permission denied (publickey)" | |
| 35 | | 4 | `/var/lib/git/git-shell-commands/shithubd` wrapper sourcing web.env then exec'ing the bare binary | `roles/shithubd/files/git-shell-commands-shithubd` | git-shell rejects forced command; "fatal: unrecognized command" | |
| 36 | | 5 | `/etc/shithub/web.env` mode 0640 (group=shithub) so the git user can read it through the wrappers above | `roles/shithubd/tasks/main.yml` | `ssh-shell: cfg: ... permission denied` | |
| 37 | | 6 | `SHITHUB_AUTH__SSH__{ENABLED,HOST}` env vars on web.env | `roles/shithubd/templates/web.env.j2` | repo pages don't show the SSH clone URL (sshd path still works) | |
| 38 | |
| 39 | Verify: |
| 40 | |
| 41 | ```sh |
| 42 | # (a) repo page now shows the SSH clone URL |
| 43 | curl -fsS https://shithub.sh/tenseleyflow/shithub | grep -i 'ssh' | head |
| 44 | |
| 45 | # (b) the git user exists with git-shell |
| 46 | ssh root@shithub.sh 'getent passwd git' |
| 47 | # git:x:117:65534::/var/lib/git:/usr/bin/git-shell |
| 48 | |
| 49 | # (c) AKC handshake works for an unknown fingerprint (returns nothing, |
| 50 | # exits 0 — that's the fail-closed contract): |
| 51 | ssh root@shithub.sh '/usr/local/bin/shithubd ssh-authkeys SHA256:not-a-real-key 2>&1; echo rc=$?' |
| 52 | # (empty output, rc=0) |
| 53 | |
| 54 | # (d) end-to-end clone with a key you've added at /settings/keys: |
| 55 | ssh-keygen -t ed25519 -f ~/.ssh/shithub-test -N '' -C 'shithub-ssh-test' |
| 56 | cat ~/.ssh/shithub-test.pub |
| 57 | # paste into https://shithub.sh/settings/keys |
| 58 | GIT_SSH_COMMAND='ssh -i ~/.ssh/shithub-test -o IdentitiesOnly=yes' \ |
| 59 | git clone git@shithub.sh:tenseleyflow/shithub.git /tmp/shithub-ssh-clone |
| 60 | ``` |
| 61 | |
| 62 | If (d) hangs or rejects: |
| 63 | - `journalctl -u ssh -n 50 --no-pager | grep -E "git|AKC|authkeys"` |
| 64 | - `journalctl -u shithubd-web -n 100 | grep ssh-shell` |
| 65 | - Manually invoke the AKC with the real fingerprint: |
| 66 | `ssh root@shithub.sh "/usr/local/bin/shithubd ssh-authkeys SHA256:<your-fp>"` |
| 67 | Should print one line starting with `command="...shithubd ssh-shell..."`. |
| 68 | |
| 69 | ## When something breaks |
| 70 | |
| 71 | | Symptom | Likely cause | |
| 72 | |---|---| |
| 73 | | `git clone git@shithub.sh:...` hangs at "Connecting" | sshd not listening on 22, or firewall blocking. `systemctl status ssh; ufw status` | |
| 74 | | Hangs at "Permission denied (publickey)" | Key not in user's `/settings/keys`, OR fingerprint mismatch. The DB stores `SHA256:...` from `ssh-keygen -lf <key>`. | |
| 75 | | "Permission denied" right after key offer | AKC returned an empty string (key DOES match no row). Run `shithubd ssh-authkeys <your-fp>` manually as root to debug. | |
| 76 | | Connects then "fatal: protocol error" | `ssh-shell` rejected the requested op. Check `journalctl -u shithubd-web` for the `ssh-shell: denied` entry. | |
| 77 | | `git push` works but post-receive hooks don't fire | `SHITHUB_*` env not threaded through. Check `protocol.PrepareDispatch` — that's the env-build site. | |
| 78 | |
| 79 | ## Disabling without removing |
| 80 | |
| 81 | Set `SHITHUB_AUTH__SSH__ENABLED=false` in `web.env` and restart |
| 82 | shithubd-web. The clone widget hides the SSH URL but the sshd path |
| 83 | still works for anyone who knows it. Use this when the SSH service |
| 84 | is having issues but you don't want to alarm people who try the UI. |
| 85 | |
| 86 | To fully disable: comment the `Match User git` block in |
| 87 | `/etc/ssh/sshd_config` and reload sshd. The `git` user can stay. |