Skip to main content

Module ssh

Module ssh 

Source
Expand description

SSH transport for git (D31): the host’s sshd, a forced command, and this shim.

The node speaks git over HTTPS with basic auth, which is what every agent uses and what no human expects. git@host:owner/repo.git is the address a person types, and the way to serve it here is the way Gitea and gitolite serve it: one OS account, one line per registered key in its authorized_keys, and a forced command that never gives out a shell.

command="choir-ssh --root /srv/repos --user alice --acl-file /etc/choir/acl \
  --handoff /srv/repos/.choir/ssh-handoff",restrict ssh-ed25519 AAAA... alice

There is deliberately no SSH server inside the daemon. Every Rust SSH library within reach is tokio-based, and this workspace is synchronous threads everywhere except choir-actor; an in-process server would make an async runtime the largest dependency the project has taken, to terminate a protocol the operating system already terminates.

The three questions stay split exactly as they are on the HTTP path:

  • which key — sshd answers it, by matching the presented public key against authorized_keys;
  • which actor — the matched line answers it, because --user is written into that key’s forced command by the operator. The client cannot influence it: sshd runs the forced command and puts whatever the client asked for in SSH_ORIGINAL_COMMAND instead;
  • which repositoryAcl answers it, the same file and the same crate::acl::git_requirement mapping the HTTP route consults, so the two paths cannot drift into granting different things.

A push then runs the repository’s pre-receive hook like any other push, because Shim::decide hands the sequencer callback to git in the environment. That is the point of the whole exercise: an SSH push joins the same total order as an HTTPS one, rather than being a second, unsequenced way in. A shim with no --handoff refuses pushes outright rather than let one through unsequenced.

The operator’s guide to every way a client reaches a node:

§Transports and the browser surface

SurfaceForDecision
Git over HTTPSagents, CI, anything holding a token
The read-only browser pagea person who wants to lookD28
Repository browsing under /r/reading code and reviews without a cloneD30
Git over SSHpeople who expect user@host:owner/repo.gitD31

All four sit behind the same auth wall and --acl-file grants; a repository you hold no grant on answers 404 on all four.

§Browser surface

/ with a credential serves the repository index and one read-only page per repository: refs, the review queue, the latest ref-state attestation, workspaces, and sequencer health.

Exception: the bare address (D57). GET / with no credential gets a static front page: what a node is, the commands to use one, and that it is invite-only. A wrong credential still gets 401.

Pages are server-rendered from /api/view, cached by view sequence, and revalidated with an ETag.

Browser writes exist in one place (D39): a verdict or comment on a review page, and passkey enrolment on /account, signed with a key that never leaves the device.

The client half is /static/webauthn.js. Its two pages are served script-src 'self'; every other page, including /r/, is default-src 'none'.

§The pages that are not repositories

URLAnonymousSigned in
/, /index.htmlthe front page (D57)the repository index
/signinthe sign-in form (D74)the same form
/join?i=&k=an invite to redeem (D57)the same
/account401passkeys, and the token git speaks (D71, D75)
/people401the operator console, @node write only, 403 otherwise (D72)
/status401node telemetry and the view
/p/<channel>401one actor’s standing (D63)
/robots.txtthe crawl policythe same
/static/card.pngthe social-preview cardthe same
/llms.txt, /sync.md401the machine-readable surface

A text/html request not naming a .git path gets the sign-in page as the body of its 401 (D74); git, curl and API clients get a bare 401 with WWW-Authenticate. Repositories granted to @anon are the exception (D78).

crates/choir-node/tests/it/routes.rs holds the expected status of every route for three readers and crawls the surface.

§The other half of the site (D76)

The node is at the apex; the book is on a docs. subdomain. Each is told where the other is at run time.

Point the node at the book. One line, no restart:

mkdir -p <root>/.choir
printf 'https://<docs-host>\n' > <root>/.choir/docs-url

<root> is the repositories directory. The value must be absolute http:// or https://. Read per request.

Point the book at the node. Two repository variables, read by .github/workflows/pages.yml:

VariableValueEffect
NODE_URLhttps://<node-host>the book’s front page links back to the node
DOCS_DOMAIN<docs-host>writes CNAME into the Pages artifact, and switches site-url to /

Also enter the domain under Settings → Pages and add a DNS CNAME for <docs-host> pointing at the Pages host.

The workflow passes CHOIR_DOCS_REPO_BASE, which repoints links to files outside docs/ at the commit being published.

§Repository browsing

/r/ lists the repositories your credential may read:

URLShows
/r/<owner>/<repo>the default branch at the repository root
/r/<owner>/<repo>/tree/<rev>/<path>a directory listing
/r/<owner>/<repo>/blob/<rev>/<path>one file, with line numbers
/r/<owner>/<repo>/commits/<rev>recent history
/r/<owner>/<repo>/commit/<oid>one commit and its diff
/r/<owner>/<repo>/reviewsreviews proposing to land here
/r/<owner>/<repo>/review/<id>one review: proposal, reviewers, verdicts, diff

Same read grant a clone needs. Content pages revalidate on the commit oid.

Files over 512 KiB are described, binaries are named, diffs truncate at 2,000 lines. Revision arithmetic, traversal and option-shaped input are refused at the router.

A review page shows the target ref, reviewers and verdicts, approval weight, slashing, and a three-dot diff. Comments are signed PostComment operations (D38).

§Git compatibility path

choir repo url owner/repo.git      # prints the URL, and the git config for the credential
git clone http://127.0.0.1:8417/owner/repo.git
git -C repo config credential.helper '!choir git-credential ~/.choir/auth'

git push origin HEAD:main

The credential is not in the URL. Pushes are CAS-sequenced: on rejection, fetch, rebase or merge, push again.

[!WARNING] Never force-push over a sequencer rejection. See The contribution workflow.

§Git over SSH (D31)

The host’s sshd serves git@host:owner/repo.git through a forced command that hands each connection to choir-ssh.

Start the node with a handoff file, which carries the daemon’s address and loopback secret:

choir-node <repo-root> 8417 --auth-file <auth-file> --keys-file <keys-file> \
  --acl-file <acl-file> --ssh-handoff <handoff-file>

One authorized_keys line per registered key:

command="/usr/local/bin/choir-ssh --root <repo-root> --user <choir-user> --acl-file <acl-file> --handoff <handoff-file> --git-binary /usr/bin/git",restrict ssh-ed25519 AAAA... <user>@<host>

--user is the choir username for that key. restrict turns off pty, agent, port and X11 forwarding. Set --git-binary explicitly.

git clone ssh://<ssh-account>@<SERVER_IP>/owner/demo.git
git clone <ssh-account>@<SERVER_IP>:owner/demo.git

The shim serves:

  • exactly git-upload-pack '<repo>' and git-receive-pack '<repo>', one argument, never a shell. Anything else is refused.
  • owner/repo or owner/repo.git, two ASCII segments, none starting with a dot.
  • the same --acl-file as HTTP: read to fetch, write to push. Omit --acl-file to use the daemon’s.
  • pushes through the repository’s pre-receive hook, sequenced like HTTPS.

[!CAUTION] A shim installed without --handoff serves fetches and refuses pushes.

Limits:

  • the handoff file holds the loopback secret at 0600, so the SSH account and the daemon must be the same uid.
  • one line per key; revocation is deleting the line. With --accounts-file the node writes those lines (above); point AuthorizedKeysFile at the generated file.
  • sshd stays the operator’s.

Structs§

Exec
The git invocation a resolved request becomes.
Handoff
Where the shim sends the sequencer callback, and the loopback secret that callback authenticates with.
Request
A parsed SSH_ORIGINAL_COMMAND: what the client asked to run, and on which repository, before any of it is believed.
Shim
One invocation’s configuration, all of it from the flags the operator wrote into the forced command.

Enums§

Service
The git service a client asked for.

Functions§

canonical_repo
Canonical repository name for a path a client sent: owner/repo.git, the same spelling the smart-HTTP path derives from a URL.
parse_command
Parses the command sshd put in SSH_ORIGINAL_COMMAND.
write_handoff
Writes a Handoff file, readable only by the user that wrote it.