| 1 | # 0019 — SSH is a second transport, not a second git |
| 2 | |
| 3 | **Status:** adopted |
| 4 | |
| 5 | ## Decision |
| 6 | |
| 7 | SSH carries the same two git services HTTP carries, over the same services, to |
| 8 | the same commit point. The listener is a transport adapter: it authenticates a |
| 9 | connection, authorizes a repository through `RepositoryAccessService`, and hands |
| 10 | streams to `PackProcessService` and `GitService`. |
| 11 | |
| 12 | Three rules make it a transport rather than a second implementation: |
| 13 | |
| 14 | 1. **One access decision.** |
| 15 | adds no permission logic of its own. |
| 16 | 2. **One commit point.** A push over SSH is spooled, committed to the log with |
| 17 | `PushTransactionService.commitPush`, then replayed into |
| 18 | `git receive-pack --stateless-rpc` — the order [0005] |
| 19 | requires. |
| 20 | 3. **No shell, ever.** A session runs |
| 21 | and nothing else. |
| 22 | |
| 23 | ## Authentication is a key, and only a key |
| 24 | |
| 25 | SSH authenticates the *connection*, before any repository or command is known. |
| 26 | OpenSSH probes with the `none` method first and only offers a key once the |
| 27 | server rejects it, so a server that accepts `none` never sees the client's key. |
| 28 | |
| 29 | That leaves one workable shape: **publickey or nothing**. A connection carries a |
| 30 | key that belongs to an account, or it does not connect. |
| 31 | |
| 32 | Anonymous clone therefore lives on HTTP, where `decideAccess` already serves a |
| 33 | public repository to `actor: null`, the response is cacheable, and the pre-push |
| 34 | probe ([0014](0014-the-pre-push-probe.md |
| 35 | |
| 36 | **Rejected:** a magic `anonymous@ |
| 37 | read-only policy. Both reintroduce an unauthenticated path for something HTTP |
| 38 | already does better. The second listener stays the answer if anonymous SSH is |
| 39 | ever actually needed — the policy belongs to the endpoint, never to a username. |
| 40 | |
| 41 | ## Why fetch and push are shaped differently |
| 42 | |
| 43 | `upload-pack` runs as a plain duplex: the channel is piped into git, git's |
| 44 | output is piped back, and nothing is buffered. It is the faster path and the |
| 45 | simpler one. |
| 46 | |
| 47 | `receive-pack` cannot be, because the log has to accept a push *before* |
| 48 | sees it, and the ref commands can only be read from the front of a body that |
| 49 | must still be replayed in full. So a push is spooled to a temp file |
| 50 | ([0015](0015-nothing-holds-a-packfile.md |
| 51 | `--stateless-rpc`, exactly as the HTTP transport does. The server writes the |
| 52 | advertisement itself, from `--advertise-refs`, so the capabilities the client |
| 53 | answers are the ones `--stateless-rpc` will read back. |
| 54 | |
| 55 | This is safe because `git send-pack` closes its side of the channel after |
| 56 | writing the packfile and before reading the report — verified against a real |
| 57 | client, not assumed. |
| 58 | |
| 59 | ## Two details that cost a debugging session each |
| 60 | |
| 61 | - **A pipe must not close the channel.** |
| 62 | channel when git exits, and the `exit-status` message then arrives on a |
| 63 | closed channel. The client sees no exit status and calls a perfectly good |
| 64 | fetch `remote transport reported error`. Both pipes use `end: false`, and the |
| 65 | channel is closed after `exit()`. |
| 66 | - **The connection must outlive the channel.** Ending the connection when the |
| 67 | command finishes truncates whatever is still queued behind the SSH |
| 68 | flow-control window — invisible on a small repository, fatal on a large one. |
| 69 | The connection closes when the channel closes. |
| 70 | |
| 71 | ## Consequences |
| 72 | |
| 73 | - A host key is configuration (`GIT_SSH_HOST_KEY`). Without it the listener |
| 74 | never starts, which doubles as the feature flag. It is never generated at |
| 75 | boot: a new host key on every restart is a changed-host-key warning for every |
| 76 | user. |
| 77 | - The SSH login name is ignored. The key names the account, so every URL uses |
| 78 | `git@`. |
| 79 | - A push over SSH materializes the repository twice — once to advertise, once |
| 80 | inside `receivePack`. The second is an index read against storage, and |
| 81 | keeping one commit path is worth it. |