Git transport
This page explains how git clients talk to Openhub. Pushes are accepted only over SSH, by a built-in SSH server. Clones and fetches work over SSH and over read-only smart HTTP. In every case the git protocol itself is handled by the installed git program, and Openhub decides who may run it against which repository.
Source files:
src/ssh.rs: the SSH server.src/web/git_http.rs: smart HTTP.src/sync.rs: bookkeeping after a push.src/web/code.rs(archive): downloads.src/config.rs: clone URLs.
Channels at a glance
| Channel | Operations | Authentication | Runs |
|---|---|---|---|
SSH (--ssh-addr, default port 2222) |
clone, fetch, push | SSH public key | git upload-pack or git receive-pack |
Smart HTTP (--http-addr, default port 3000) |
clone, fetch | None for public repositories; Basic auth for private ones | git upload-pack --stateless-rpc |
| HTTP archive download | ZIP or tar.gz of a branch, tag or commit | Browser session, or anonymous for public repositories | git archive |
Clone URLs
Config builds the URLs shown in the Clone dropdown, in push hints and in the documentation pages:
| URL | Built by | Example |
|---|---|---|
| SSH, port 22 | ssh_clone_url |
[email protected]:alice/project.git |
| SSH, any other port | ssh_clone_url |
ssh://[email protected]:2222/alice/project.git |
| HTTP | http_clone_url |
https://git.example.com/alice/project.git |
| SSH test command | ssh_test_command |
ssh -T -p 2222 [email protected] |
The SSH host is --ssh-host, or failing that the host of --public-url. The SSH port is --ssh-port, or failing that the port of --ssh-addr. Set both when Openhub runs behind port forwarding, for example container port 2222 published as host port 22.
SSH server
The server is built on russh (ssh::serve) and listens on --ssh-addr.
Host key
On first start, Openhub generates an Ed25519 host key at <data>/ssh_host_ed25519_key, in OpenSSH format with mode 0600, and reuses it on every later start. Its SHA-256 fingerprint is logged at startup so users can check it. If the key is lost, every client sees a "host key changed" warning, so keep it in backups.
Server settings
| Setting | Value |
|---|---|
| Version string | SSH-2.0-Openhub |
| Authentication methods | publickey only. No passwords or keyboard-interactive |
| Delay after a rejected authentication | 300 ms (none for the first "none" probe) |
| Inactivity timeout | 1 hour |
| Keepalive interval | 60 s |
Authentication
auth_publickey_offered. The client offers a public key, and Openhub accepts the offer only if its fingerprint is inssh_keys. Clients therefore only sign with keys that will work, and move on to the next key otherwise.auth_publickey. russh has verified the client's signature. Openhub loads the owning user, updateslast_used_atand logs the sign-in with the peer address.
The SSH username is ignored. Anything works, but clone URLs use git.
Channel requests
| Request | Response |
|---|---|
env GIT_PROTOCOL=… |
Stored and passed to git, which enables protocol v2. Other variables are refused |
pty-req |
Refused |
shell (for example ssh -T) |
Prints Hi <user>! You've successfully authenticated, but Openhub does not provide shell access. and exits with status 1 |
subsystem (for example sftp) |
Refused |
exec |
Parsed as a git command. See below |
Command parsing
parse_command accepts exactly these forms, which are what git clients send:
git-upload-pack '<path>'
git-receive-pack '<path>'
git upload-pack '<path>'
git receive-pack '<path>'
It then processes the path:
- Removes surrounding single or double quotes.
- Rejects the path if it still contains
',",\or a NUL byte. - Trims
/from both ends, then removes a trailing.git. - Requires exactly
owner/name.
Anything else, including git-upload-archive (that is, git archive --remote), gets ERROR: Openhub only supports git fetch and push over SSH. and exit status 1.
Authorisation and execution
SshSession::exec_request does the following:
-
Looks up the repository with
db::repo_by_nameand callsAppState::access. -
Checks permissions:
- No read access, or no such repository:
ERROR: Repository not found. receive-packwithout write access:ERROR: You do not have permission to push to owner/name.
- No read access, or no such repository:
-
For
receive-pack, records every branch tip (branch_tips), so push hints can tell what changed. -
Spawns
<git-bin> upload-pack|receive-pack <data>/repos/<id>.git:- The path comes from the database ID, never from the client's string.
- No shell is involved.
- stdin, stdout and stderr are piped.
kill_on_dropis set.GIT_PROTOCOLis passed through when the client sent it.
-
Relays data:
client ──SSH data──▶ SshSession::data ──unbounded mpsc──▶ task ──▶ git stdin client ◀─SSH data─── handle.data ◀── forward() ◀───────────── git stdout client ◀─ext. data── handle.extended_data(1) ◀── forward() ◀────── git stderr client EOF ─────────▶ channel_eof drops the sender ──────────────▶ git stdin closed -
After git exits, sends git's exit status, then EOF, then closes the channel.
Why the Channel object is dropped. channel_open_session drops russh's Channel handle on purpose and receives data through the data callback instead. Holding the handle without reading from it fills russh's per-channel buffer and deadlocks the session. The trade-off is that the queue to git's stdin is unbounded (see Security).
Protecting refs/pull
Every repository is created with receive.hideRefs=refs/pull. git receive-pack then neither advertises refs/pull/* nor accepts updates to it: a push there fails with deny updating a hidden ref. upload-pack still advertises these refs, so clients can fetch them.
After a push
When receive-pack exits with status 0, two things happen before the exit status is sent:
-
sync::after_pushruns:db::touch_pushedsetspushed_at.git::ensure_headmakes sureHEADnames an existing branch. It prefers the current default branch, thenmain, thenmaster, then the first branch alphabetically. If it had to pick a different branch,repositories.default_branchis updated. That's how a repository's first push of a non-mainbranch becomes its default.sync::refresh_pullsre-evaluates every open pull request in the repository. It records new heads and marks pull requests as merged when their head is now in the base (see Pull requests).
-
Push hints (
push_hints): for each non-default branch whose tip changed in this push, Openhub prints one of the following on stderr. The client shows them prefixed withremote:.Create a pull request for 'feature' on Openhub by visiting: https://git.example.com/alice/project/compare/main...featureView pull request for 'feature': https://git.example.com/alice/project/pull/3Deleted branches and the default branch get no hint.
Errors in after_push are logged and don't fail the push, which by then has already been accepted.
Smart HTTP (read-only)
Routes, with {repo} accepting an optional .git suffix:
| Request | Result |
|---|---|
GET /{owner}/{repo}/info/refs?service=git-upload-pack |
Ref advertisement from git upload-pack --stateless-rpc --advertise-refs. Unless protocol v2 was requested, it's prefixed with the # service=git-upload-pack pkt-line and a flush packet |
POST /{owner}/{repo}/git-upload-pack |
Request body (gzip decoded if Content-Encoding: gzip) piped to git upload-pack --stateless-rpc; the output is streamed back |
GET …/info/refs?service=git-receive-pack |
403: "Pushing over HTTP is not supported. Add an SSH key in your Openhub settings and push over SSH." |
POST …/git-receive-pack |
403 |
GET …/info/refs with no service (dumb HTTP) |
403: only the smart protocol is supported |
Details:
- Authorisation (
git_http::authorize):- A missing repository gets 404.
- A public repository needs no credentials.
- A private repository needs Basic auth. Missing or wrong credentials get 401 with
WWW-Authenticate: Basic realm="Openhub". Valid credentials without read access get 404.
- Protocol v2. The
Git-Protocolheader is passed through asGIT_PROTOCOLwhen it contains only[A-Za-z0-9=:.]. - Body limit. The
git-upload-packroute accepts request bodies up to 64 MB (GIT_BODY_LIMITinsrc/web/mod.rs). Negotiation for large repositories lists many commits. Other routes keep axum's 2 MB default. - Middleware. These paths skip the session lookup and the cross-origin check, because git clients send neither cookies nor
Origin(seectx::is_git_request). - Errors. git's stderr is discarded. Failures are logged and returned as 500 with a short message.
Archive downloads
GET /{owner}/{repo}/archive/{ref}.zip or .tar.gz (code::archive):
- Checks read access with
load_repo. - Resolves
{ref}withgit::resolve, which accepts a branch, tag or commit SHA. The ref must not include a path. - Runs
git --git-dir <path> archive --format=zip|tar.gz --prefix=<repo>-<ref>/ <commit-sha>and streams stdout to the client asattachment; filename="<repo>-<ref>.<ext>". Any/in the ref becomes-.
git archive honours the repository's own .gitattributes, including export-ignore and export-subst. See Security for why that matters.
Protocol features
The installed git provides the protocol support:
- Protocol v0, v1 and v2.
- Shallow clones (
--depth). - Side-band progress.
- Atomic pushes.
Openhub doesn't set uploadpack.allowFilter, so partial clone filters (--filter=blob:none) are ignored by the server. It doesn't install hooks, and nothing in the protocol can write a repository's config or hooks. Git LFS isn't supported.
When you change this
- New SSH command: add it to
Serviceandparse_command, together with its unit test. Decide whichAccesslevel it needs. Extend the smoke test. - New post-push behaviour: put it in
sync::after_push, so web merges and pushes share it where it applies. - New git configuration for hosted repositories: add it to
git::configure. Existing repositories don't pick it up automatically, so add a startup step if they need it. - Changes to HTTP authentication: keep private repositories indistinguishable from missing ones, and keep pushes on SSH unless that is decided otherwise.