Expand description
The russh server: authentication, the exec dispatch, and the channel hand-off (the ACL model, the two-listener split).
§The shape of a connection
auth_none, the anonymous read path, and the only place gunnar reads the SSH username. See below.auth_publickey: russh has already verified that the client owns the offered key. gunnar fingerprints it (SHA-256 over the wire blob, OpenSSH’s printed form,crate::access::fingerprint_of_key) and asksgunnar-core’s identity table throughControl. The SSH username is logged and otherwise ignored: identity comes from the key, sogit@host:ns/repo.gitworks andgitmeans nothing (the ACL model).channel_open_session. The channel is kept, because the exec request arrives as a separate message that only carries aChannelId, and the stream can only be taken from theChannelitself. A watchdog is armed here so a session that authenticates and then never execs cannot sit on the connection.env_request, answered againstENV_ALLOWLIST, which holds exactly one name:GIT_PROTOCOL. Everything else is refused. See below.exec_request:Dispatch::authoriseparses, resolves and checks the grant. Only then is the channel’s stream handed toGitTransport.- everything else.
shell_request,subsystem_request,pty_requestandx11_requestare answered with a channel failure. No shell, ever.
§Why the transport is spawned and not awaited
russh calls handler methods from the session’s own read loop (see
russh::server::Session::run). Awaiting a whole clone inside exec_request
would stop the loop that has to deliver the client’s bytes to the channel;
the transfer would deadlock on its first read. russh’s own sftp_server
example awaits inline; gunnar does not.
§The exit status, and why the channel is split rather than consumed
Channel::into_stream() wraps the channel in russh’s ChannelCloseOnDrop,
which spawns a task sending ChannelMsg::Close when the transport lets the
stream go. That Close raced the exit-status the server sent next, and
won often enough that a successful push reported failure. Measured
2026-08-03: a complete 244-byte advertisement delivered with ssh exiting
255. See crate::channel.
The channel is therefore split() instead. The read half becomes an owned
ChannelReader, the write half stays with the server, and exit-status
and close go out through the same sender the data went through, in
order, after the transport has finished with it. Nothing sleeps and nothing
races.
§One span per connection, and what hangs off it
ssh.connection { conn, peer, principal, repo } gunnar::ssh
└── git.transfer { service, access } gunnar::git
├── git.upload_pack | git.receive_pack gunnar::git
└── policy.gate { ref_name } gunnar::policyprincipal and repo are declared empty when the connection opens and
recorded when auth_publickey and exec_request learn them, so a line
emitted at any depth carries all four fields without any call site restating
them. That is the difference from what was here before: connection identity
was an ad-hoc ?self.peer copy-pasted to fourteen call sites, there was no
id at all, and two lines from the same connection could not be tied
together.
Two hand-offs would drop the span if they were not made explicit, and each is a place where an operator would silently lose half a connection:
tokio::spawn: the exec watchdog and the transfer task both take.instrument(span), because a spawned task does not inherit a thread-local span;spawn_blocking:gunnar-cli’sWireTransport::servecapturesSpan::current()and enters it on the blocking thread, which is where the whole git conversation actually happens.
§How an anonymous session arrives, and why it is auth_none on one
§username
SSH has no unauthenticated mode: a client must complete some userauth method before it may open a channel. Three shapes were available.
| why not | |
|---|---|
accept none for every username | This is the one that would break the server. OpenSSH’s client always sends a none request first, to learn the method list. A server that answers it with SUCCESS authenticates every stock client as anonymous before it ever offers its key, so every existing push would start failing, and every existing user would silently lose their identity. Measured against the russh dispatch: server/encrypted.rs calls auth_none on that first probe regardless of the advertised methods. |
| a well-known published key | The anonymous-FTP shape. It works, but a stranger has to obtain a key file before they can clone, which is worse than the problem it solves, and gunnar would then hold a key whose private half is public in the same table that authenticates real users. |
none, on one reserved username | Taken. |
ssh://anonymous@host/ns/repo.git gets the none method accepted and a
session whose principal is
Principal::anonymous. Every other
username gets none refused, exactly as russh’s default handler refused it
before this existed, and the client falls through to publickey unchanged.
Why it cannot become a bypass, in the order the objections come:
- Does it weaken private repositories? No. The username selects the
authentication method; the authorisation answer still comes from
AuthTable::may(the ACL model), where the anonymous principal is one rule,ReadonPublic, and is refused everything else includingmay_namespaceandmay_server. There is no second ACL (one implementation). - Can a caller claim to be someone by picking a username? No. The
anonymous principal is the only identity a username can select, it is
never in the identity table,
Principal::newandAuthTable::add_principalrefuse its id, so no key and no grant can ever attach to it. - Can an authenticated user be silently downgraded? No.
noneis refused for their username, so nothing changes for them; and a user who does typeanonymous@asked for strictly less access, which is not an escalation. - Does the username now reach an authorisation decision, against the ACL model? It reaches exactly one, in the direction of removing everything. The ACL model’s rule exists so that a username cannot be used to become somebody; this uses it to become nobody.
none is not advertised in RusshConfig::methods, which stays
publickey-only. Advertising it would tell every client that none is
available when it is available only for one name (the failure list is
supposed to be true), and russh calls auth_none on the client’s opening
probe either way, which is the only place it is ever needed.
SshConfig::allow_anonymous closes the
whole path at the listener for an appliance that wants it shut.
§GIT_PROTOCOL, and why it is an allowlist of one
A git client announces protocol v2 over SSH by sending
GIT_PROTOCOL=version=2 as a channel environment request. Refusing every
env_request, which this server used to do, meant every session fell back
to v0. Stock git copes; gix’s blocking v0 client does not, because it
requires multi_ack_detailed and gunnar_wire::caps correctly declines to
advertise a capability it does not implement. The measured consequence was
that gunnar clone could not clone from gunnar serve at all.
Accepting the variable is not the same as accepting an environment. An
SSH server that passes arbitrary client-supplied names through has handed
the client LD_PRELOAD, PATH and every other lever there is. ENV_ALLOWLIST
names the one variable gunnar reads; every other name is refused with a
channel failure, exactly as before. The accepted value is not an input to
any authorisation decision: it reaches ServiceRequest::git_protocol and
nothing else, and it only ever selects the framing the wire answers in.
Structs§
- Bound
Server - A bound, running listener.
- Dispatch
- Parse → resolve → check, with no SSH anywhere in it.
- Gunnar
Ssh - gunnar’s SSH listener.
- RepoAsk
- One
REPO_VERBask, with who asked: the principal the session’s key authenticated as (re-read at the exec, never cached) and that key’s fingerprint.
Enums§
- Dispatch
Error - Why an exec request was not served.
- Repo
Action - What a
REPO_VERBline asks.
Constants§
- ACCEPT_
BACKOFF - How long the accept loop waits after the kernel refused it a descriptor,
before it asks again. A session ending in that window frees what the next
acceptneeds; nothing shorter gains anything, nothing longer is felt. - ENV_
ALLOWLIST - Every environment variable this server accepts from a client.
- ENV_
GIT_ PROTOCOL - The one environment variable a git client may send: how it asks for protocol v2 over SSH.
- ENV_
ROUTINE - The refused names that mean nothing at all, because OpenSSH sends them on its own without being asked.
- EXIT_
BUSY - Exit status reported when the admission gate refused a transfer.
- EXIT_
DENIED - Exit status reported when access was refused. Matches what
gititself reports for a failed remote helper. - EXIT_
TRANSPORT_ FAILED - Exit status reported when the wire failed mid-transfer.
- FIRE_
DRILL_ VERB - The operator’s fire drill (stinkies ssh-double-exec-error, 2026-09-30): ONE exec
verb, held to server-wide Admin like
TUNNEL_ADDRESS_VERB, that says exactly one ERROR naming itself a drill and who asked — what the estate’s watch raises on purpose to prove its alert path end to end. Nothing is wrong when it is said; no other caller can raise the server’s ERROR at will. - LFS_
AUTHENTICATE_ MESSAGE - What
LFS_AUTHENTICATE_VERBanswers, on stderr with exit 1. It must never saynot found/no such file: git-lfs reads those as “no such verb” and goes on to a guessed HTTPS endpoint, which Gunnar does not serve. - LFS_
AUTHENTICATE_ VERB - git-lfs’s fallback verb, answered with a sentence instead of a channel
failure. Gunnar serves Git LFS over pure SSH only (
git-lfs-transfer, git-lfs >= 3.0); git-lfs asks this verb only when a pure-SSH session failed or was refused, and then prints what is on stderr afterbatch request:. - LFS_
CHANNELS_ PER_ SLOT - How many
git-lfs-transferchannels of one connection share one admission slot ([Connection::lfs_slots]): git-lfs’s pool islfs.concurrenttransfers+ 1 (9 by default); 32 leaves room for a client that raised it, and bounds what one slot carries. - MAX_
ENV_ VALUE - The longest environment value the server will hold.
- RECIPIENT_
KEYS_ VERB - A member’s SSH public keys, for an encrypted repository’s key ring
(2026-10-06):
gunnar-recipient-keys '<namespace>/<repository>' '<member>'. - REPO_
VERB - A repository’s settings, over the forge’s door (owner, 2026-10-14):
gunnar-repo show '<namespace>/<repository>',gunnar-repo visibility '<namespace>/<repository>' public|private,gunnar-repo default-branch '<namespace>/<repository>' '<refs/heads/…>'. - REVOKED_
MESSAGE - What a client is told when the key that opened its session stopped authorising it.
- SSH_
SHUTDOWN_ GRACE - How long
BoundServer::shutdownwaits for sessions to answer the disconnect before it aborts the listener. Longer than the gRPC grace (2 s): a git transfer mid-pack deserves a moment to finish its write. - STARTING_
MESSAGE - What a client is told while the server’s stores are still opening.
- TUNNEL_
ADDRESS_ VERB - The one control verb on this listener (2026-09-21, T17):
gunnar-tunnel-address [a.b.c.d/prefix]. - WHOAMI_
VERB - Who this key is (wiring tracer B 2026-10-08):
gunnar-whoamianswers the principal id the session authenticated as, one line, exit 0 — whatgunnar crypt initnames its first member by (the account nameadd-member --from-serverlooks members up by). Reveals nothing the key’s holder does not already know. An anonymous session is refused the channel. Not a git verb.
Statics§
- KEX_
ORDER - The key exchanges gunnar’s SSH door offers, post-quantum FIRST (2026-10-06, the PQ transport lane).
Functions§
- accept_
error_ is_ transient - Is this
acceptfailure the kind the loop must outlive? - env_
refusal_ is_ routine - Whether
nameis a routine refusal (ENV_ROUTINE, or anyLC_*, which OpenSSH sends as a glob). - is_
lfs_ over_ ssh - Pure: whether an exec line is git-lfs asking SSH (
git-lfs-authenticate …,git-lfs-transfer …) — refused, and said at INFO. - parse_
recipient_ keys_ verb - Parse a
RECIPIENT_KEYS_VERBexec line.None: not this verb.Some(Err): this verb, malformed. The path and the member may each be single-quoted the way git quotes a path; the path goes through the one sandbox (gunnar_core::resolve_repo_path) and the member through the principal id rule. - parse_
repo_ verb - Parse a
REPO_VERBexec line.None: not this verb.Some(Err): this verb, malformed. Each argument may be single-quoted the way git quotes a path; the path goes through the one sandbox (gunnar_core::resolve_repo_path). - parse_
tunnel_ address_ verb - Parse a
TUNNEL_ADDRESS_VERBexec line.None: not this verb.Some(Err): this verb, malformed. Printable ASCII only, at most one argument, short. - preferred
- russh’s algorithm preferences with gunnar’s
KEX_ORDER; ciphers, MACs and host-key algorithms stay russh’s. - recipient_
keys_ answer - The answer to
RECIPIENT_KEYS_VERBforcaller:(exit status, stdout). Reads the identity table and the catalog; changes nothing. - user_
key_ answer - The answer to
gunnar_crypt::door::VERB(gunnar-user-key, CRYPT-YUBI) forcaller, the session’s own principal:(exit status, stdout). Reads or changes the caller’s own encryption key only; there is no name to give.
Type Aliases§
- Refused
Local - Is a connection that arrived at this local address refused?
- Repo
Door - What answers
REPO_VERB: the ask in, thekey valuelines or the refusal’s sentence out. - Tunnel
Address Door - What answers
TUNNEL_ADDRESS_VERB: the address (empty = a read) in, the answer’s lines or the refusal out.