Git backed by object storage because you can't stop me
git object-storage kefka
10

Configure Feed

Select the types of activity you want to include in your feed.

docs: document the auth seam and SSH transport in CLAUDE.md

Xe Iaso (May 28, 2026, 11:39 PM EDT) 722736df ae5d6a27

+60 -4
+60 -4
CLAUDE.md
··· 4 4 5 5 ## What this is 6 6 7 - `objgitd` is a single-binary Git server that stores repositories as objects in a Tigris/S3 bucket instead of on a local filesystem. It speaks two transports against the same backend: 7 + `objgitd` is a single-binary Git server that stores repositories as objects in a Tigris/S3 bucket instead of on a local filesystem. It speaks three transports against the same backend: 8 8 9 - - **Smart HTTP** (`-http-bind`, default `:8080`) — primary transport, where auth middleware would wrap. 9 + - **Smart HTTP** (`-http-bind`, default `:8080`) — primary transport. Carries an HTTP Basic credential into the auth seam. 10 10 - **git://** (`-git-bind`, default `:9418`) — unauthenticated TCP, opt-in. 11 + - **SSH** (`-ssh-bind`, default off) — public-key transport, opt-in. Host key persisted in the bucket. 12 + 13 + All three funnel authorization through one pluggable `internal/auth.Authorizer` (see [The auth seam](#the-auth-seam-internalauth)). 11 14 12 15 Module path: `tangled.org/xeiaso.net/objgit`. Go 1.26. 13 16 ··· 23 26 # Run locally. Flags can also come from env via flagenv (UPPER_SNAKE of the flag name). 24 27 # A .env file in CWD is auto-loaded by godotenv. 25 28 ./objgitd -bucket $BUCKET -http-bind :8080 -allow-push 29 + ./objgitd -bucket $BUCKET -ssh-bind :2222 -allow-push # git clone ssh://git@host:2222/repo.git 26 30 ``` 27 31 32 + SSH tests additionally need `ssh` and `ssh-keygen` on PATH (skipped otherwise); 33 + run them with `go test -run TestSSH ./cmd/objgitd/...`. 34 + 28 35 `flagenv` maps `-allow-push` → `ALLOW_PUSH`, `-bucket` → `BUCKET`, etc. Tigris client credentials come from the standard AWS SDK chain (`AWS_PROFILE` etc.). 29 36 30 37 ## Architecture 31 38 32 39 ### The `daemon` is the shared backend 33 40 34 - `cmd/objgitd/main.go` constructs one `*daemon` holding `(fs billy.Filesystem, loader transport.Loader, allowPush bool)` and serves it through both transports concurrently under an `errgroup`. Repository resolution, the `allowPush` gate, and **create-on-first-push** all live on `*daemon` (`loadOrInit` in `git_protocol.go`) so both transports behave identically. 41 + `cmd/objgitd/main.go` constructs one `*daemon` holding `(fs billy.Filesystem, loader transport.Loader, authz auth.Authorizer)` (plus the hooks fields) and serves it through all three transports concurrently under an `errgroup`. Repository resolution, authorization, and **create-on-first-push** all live on `*daemon` (`loadOrInit` in `git_protocol.go`) so every transport behaves identically. 35 42 36 - - `cmd/objgitd/git_protocol.go` — git:// TCP server: `Serve` → `handle` decodes a `packp.GitProtoRequest`, then dispatches to `transport.UploadPack` / `UploadArchive` / `ReceivePack`. 43 + - `cmd/objgitd/git_protocol.go` — git:// TCP server: `Serve` → `handle` decodes a `packp.GitProtoRequest`, then dispatches to `transport.UploadPack` / `UploadArchive` / `ReceivePack`. Also holds the shared `operationFor(service)` helper (receive-pack → `auth.Write`, else `auth.Read`). 37 44 - `cmd/objgitd/http.go` — `*daemon` implements `http.Handler` directly. Dispatch is by **URL suffix** (`/info/refs`, `/git-upload-pack`, `/git-receive-pack`) because repo paths are variable-depth and `http.ServeMux` wildcards can't capture a prefix before a fixed suffix. Smart-HTTP uses the same go-git server commands with `StatelessRPC: true` (and `AdvertiseRefs: true` for `GET /info/refs`). 45 + - `cmd/objgitd/ssh.go` — SSH server (gliderlabs/ssh): `newSSHServer` builds the server and host key; `handleSSH` is the per-session dispatcher, a sibling of `handle` (see [Git over SSH](#git-over-ssh-sshgo)). 38 46 39 47 ### Two subtle protocol points 40 48 41 49 1. **`streamingStorer` in `git_protocol.go`** wraps the storer for git:// receive-pack to **hide its `PackfileWriter` capability**. `UpdateObjectStorage`'s `PackfileWriter` path uses `io.CopyBuffer` and only returns on `io.EOF`, which deadlocks over a persistent TCP socket (the client is waiting for report-status). Hiding the capability falls through to `Parser.Parse`, which knows the pack's end from the format itself. HTTP doesn't need this — request bodies have a real EOF — so HTTP keeps the faster PackfileWriter path. Trade-off: git:// pushes write loose objects (one S3 PUT per object). 42 50 43 51 2. **No-op closers everywhere.** `transport.UploadPack`/`ReceivePack` call `Close` on the reader (and sometimes the writer) between negotiation rounds. The git:// socket can't survive that, and the HTTP `ResponseWriter` doesn't implement `Close`. Wrap with `io.NopCloser` (reader) and `ioutil.WriteNopCloser` from `go-git/v6/utils/ioutil` (writer). 52 + 53 + **SSH shares both gotchas with git://**, not HTTP: an `ssh.Session` is a persistent bidirectional stream, so `handleSSH` uses `streamingStorer{}` for receive-pack and wraps the session in the same no-op closers. (HTTP keeps the faster `PackfileWriter` path because the request body has a real EOF.) 54 + 55 + ### The auth seam (`internal/auth`) 56 + 57 + Every transport authorizes through one interface so a real authn/authz layer can 58 + drop in without touching transport code. `internal/auth` is deliberately 59 + transport-neutral — it imports only `context` and `golang.org/x/crypto/ssh` (for 60 + the public-key wire type), **not** gliderlabs/ssh or go-git. 61 + 62 + - `Authorizer.Authorize(ctx, auth.Request) auth.Decision`. The `Request` carries 63 + `Repo`, `Operation` (`Read`/`Write`), a `Credential`, and a `Transport` tag. 64 + - `Credential` is a sum type (sealed via an unexported method): `Anonymous{}` 65 + (git://, or HTTP/SSH with nothing presented), `PublicKey{Key}` (SSH), and 66 + `BasicAuth{Username, Password}` (HTTP — **unvalidated**; the Authorizer owns 67 + the user store). 68 + - `Decision` is `Allow` / `Deny` / `Unauthenticated`. `Unauthenticated` is the 69 + seam that lets HTTP issue a `401 WWW-Authenticate` challenge; SSH and git:// 70 + treat anything other than `Allow` as denial. 71 + 72 + Each transport's job is only to **collect** the credential, **map** the git 73 + service to an `Operation` (via `operationFor`), and **render** the `Decision` in 74 + its own dialect (pktline error / `401`/`403` / stderr + non-zero exit). The lone 75 + implementation today is `auth.AllowAnonymous{AllowWrite}`: read for everyone, 76 + write only when set — wired in `main.go` as `AllowAnonymous{AllowWrite: *allowPush}`, 77 + so `-allow-push` is now just this default's config rather than a field on `daemon`. 78 + 79 + ### Git over SSH (`ssh.go`) 80 + 81 + A third sibling to `git_protocol.go` / `http.go`. Like git:// (and unlike wish's 82 + `git/git.go`, which execs the real `git-upload-pack` binary), objgitd answers the 83 + protocol **natively** with the same `transport.*` functions — no `git` binary, no 84 + on-disk checkout. `handleSSH` mirrors `handle`: `s.Command()` is already 85 + shlex-split by gliderlabs/ssh, so `gitServiceFor(cmd[0])` selects the service, the 86 + repo path is `strings.TrimPrefix(cmd[1], "/")` (so `ssh://host/foo.git` and 87 + scp-style `host:foo.git` resolve the same), and the session is the protocol 88 + stream (reader + writer). 89 + 90 + - **Connect vs. authorize.** `PublicKeyHandler` returns `true` for every key — it 91 + must be set or the server won't offer pubkey auth at all — and authorization 92 + happens per-command via `d.authz` (`Cred: auth.PublicKey{Key: s.PublicKey()}`). 93 + - **Host key** lives in the bucket at `.objgit/ssh_host_ed25519_key` 94 + (`loadOrCreateHostKey`): generated ed25519 on first start, reused after, so no 95 + host-key-changed warnings across restarts. No local-disk dependency. 96 + - Receive-pack goes through **`d.receivePack`** (not `transport.ReceivePack`), so 97 + push hooks fire over SSH too. 98 + - Protocol v2 (`GIT_PROTOCOL` via `s.Environ()`) is intentionally not forwarded 99 + yet; v0/v1 is sufficient. 44 100 45 101 ### Push hooks (`hooks.go`, sandboxed via kefka) 46 102