Skip to main content

Module server

Module server 

Source
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

  1. auth_none, the anonymous read path, and the only place gunnar reads the SSH username. See below.
  2. 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 asks gunnar-core’s identity table through Control. The SSH username is logged and otherwise ignored: identity comes from the key, so git@host:ns/repo.git works and git means nothing (the ACL model).
  3. channel_open_session. The channel is kept, because the exec request arrives as a separate message that only carries a ChannelId, and the stream can only be taken from the Channel itself. A watchdog is armed here so a session that authenticates and then never execs cannot sit on the connection.
  4. env_request, answered against ENV_ALLOWLIST, which holds exactly one name: GIT_PROTOCOL. Everything else is refused. See below.
  5. exec_request: Dispatch::authorise parses, resolves and checks the grant. Only then is the channel’s stream handed to GitTransport.
  6. everything else. shell_request, subsystem_request, pty_request and x11_request are 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::policy

principal 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’s WireTransport::serve captures Span::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 usernameThis 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 keyThe 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 usernameTaken.

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, Read on Public, and is refused everything else including may_namespace and may_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::new and AuthTable::add_principal refuse its id, so no key and no grant can ever attach to it.
  • Can an authenticated user be silently downgraded? No. none is refused for their username, so nothing changes for them; and a user who does type anonymous@ 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§

BoundServer
A bound, running listener.
Dispatch
Parse → resolve → check, with no SSH anywhere in it.
GunnarSsh
gunnar’s SSH listener.
RepoAsk
One REPO_VERB ask, with who asked: the principal the session’s key authenticated as (re-read at the exec, never cached) and that key’s fingerprint.

Enums§

DispatchError
Why an exec request was not served.
RepoAction
What a REPO_VERB line 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 accept needs; 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 git itself 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_VERB answers, on stderr with exit 1. It must never say not 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 after batch request:.
LFS_CHANNELS_PER_SLOT
How many git-lfs-transfer channels of one connection share one admission slot ([Connection::lfs_slots]): git-lfs’s pool is lfs.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::shutdown waits 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-whoami answers the principal id the session authenticated as, one line, exit 0 — what gunnar crypt init names its first member by (the account name add-member --from-server looks 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 accept failure the kind the loop must outlive?
env_refusal_is_routine
Whether name is a routine refusal (ENV_ROUTINE, or any LC_*, 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_VERB exec 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_VERB exec 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_VERB exec 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_VERB for caller: (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) for caller, 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§

RefusedLocal
Is a connection that arrived at this local address refused?
RepoDoor
What answers REPO_VERB: the ask in, the key value lines or the refusal’s sentence out.
TunnelAddressDoor
What answers TUNNEL_ADDRESS_VERB: the address (empty = a read) in, the answer’s lines or the refusal out.