···4455## What this is
6677-`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:
77+`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:
8899-- **Smart HTTP** (`-http-bind`, default `:8080`) — primary transport, where auth middleware would wrap.
99+- **Smart HTTP** (`-http-bind`, default `:8080`) — primary transport. Carries an HTTP Basic credential into the auth seam.
1010- **git://** (`-git-bind`, default `:9418`) — unauthenticated TCP, opt-in.
1111+- **SSH** (`-ssh-bind`, default off) — public-key transport, opt-in. Host key persisted in the bucket.
1212+1313+All three funnel authorization through one pluggable `internal/auth.Authorizer` (see [The auth seam](#the-auth-seam-internalauth)).
11141215Module path: `tangled.org/xeiaso.net/objgit`. Go 1.26.
1316···2326# Run locally. Flags can also come from env via flagenv (UPPER_SNAKE of the flag name).
2427# A .env file in CWD is auto-loaded by godotenv.
2528./objgitd -bucket $BUCKET -http-bind :8080 -allow-push
2929+./objgitd -bucket $BUCKET -ssh-bind :2222 -allow-push # git clone ssh://git@host:2222/repo.git
2630```
27313232+SSH tests additionally need `ssh` and `ssh-keygen` on PATH (skipped otherwise);
3333+run them with `go test -run TestSSH ./cmd/objgitd/...`.
3434+2835`flagenv` maps `-allow-push` → `ALLOW_PUSH`, `-bucket` → `BUCKET`, etc. Tigris client credentials come from the standard AWS SDK chain (`AWS_PROFILE` etc.).
29363037## Architecture
31383239### The `daemon` is the shared backend
33403434-`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.
4141+`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.
35423636-- `cmd/objgitd/git_protocol.go` — git:// TCP server: `Serve` → `handle` decodes a `packp.GitProtoRequest`, then dispatches to `transport.UploadPack` / `UploadArchive` / `ReceivePack`.
4343+- `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`).
3744- `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`).
4545+- `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)).
38463947### Two subtle protocol points
404841491. **`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).
425043512. **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).
5252+5353+**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.)
5454+5555+### The auth seam (`internal/auth`)
5656+5757+Every transport authorizes through one interface so a real authn/authz layer can
5858+drop in without touching transport code. `internal/auth` is deliberately
5959+transport-neutral — it imports only `context` and `golang.org/x/crypto/ssh` (for
6060+the public-key wire type), **not** gliderlabs/ssh or go-git.
6161+6262+- `Authorizer.Authorize(ctx, auth.Request) auth.Decision`. The `Request` carries
6363+ `Repo`, `Operation` (`Read`/`Write`), a `Credential`, and a `Transport` tag.
6464+- `Credential` is a sum type (sealed via an unexported method): `Anonymous{}`
6565+ (git://, or HTTP/SSH with nothing presented), `PublicKey{Key}` (SSH), and
6666+ `BasicAuth{Username, Password}` (HTTP — **unvalidated**; the Authorizer owns
6767+ the user store).
6868+- `Decision` is `Allow` / `Deny` / `Unauthenticated`. `Unauthenticated` is the
6969+ seam that lets HTTP issue a `401 WWW-Authenticate` challenge; SSH and git://
7070+ treat anything other than `Allow` as denial.
7171+7272+Each transport's job is only to **collect** the credential, **map** the git
7373+service to an `Operation` (via `operationFor`), and **render** the `Decision` in
7474+its own dialect (pktline error / `401`/`403` / stderr + non-zero exit). The lone
7575+implementation today is `auth.AllowAnonymous{AllowWrite}`: read for everyone,
7676+write only when set — wired in `main.go` as `AllowAnonymous{AllowWrite: *allowPush}`,
7777+so `-allow-push` is now just this default's config rather than a field on `daemon`.
7878+7979+### Git over SSH (`ssh.go`)
8080+8181+A third sibling to `git_protocol.go` / `http.go`. Like git:// (and unlike wish's
8282+`git/git.go`, which execs the real `git-upload-pack` binary), objgitd answers the
8383+protocol **natively** with the same `transport.*` functions — no `git` binary, no
8484+on-disk checkout. `handleSSH` mirrors `handle`: `s.Command()` is already
8585+shlex-split by gliderlabs/ssh, so `gitServiceFor(cmd[0])` selects the service, the
8686+repo path is `strings.TrimPrefix(cmd[1], "/")` (so `ssh://host/foo.git` and
8787+scp-style `host:foo.git` resolve the same), and the session is the protocol
8888+stream (reader + writer).
8989+9090+- **Connect vs. authorize.** `PublicKeyHandler` returns `true` for every key — it
9191+ must be set or the server won't offer pubkey auth at all — and authorization
9292+ happens per-command via `d.authz` (`Cred: auth.PublicKey{Key: s.PublicKey()}`).
9393+- **Host key** lives in the bucket at `.objgit/ssh_host_ed25519_key`
9494+ (`loadOrCreateHostKey`): generated ed25519 on first start, reused after, so no
9595+ host-key-changed warnings across restarts. No local-disk dependency.
9696+- Receive-pack goes through **`d.receivePack`** (not `transport.ReceivePack`), so
9797+ push hooks fire over SSH too.
9898+- Protocol v2 (`GIT_PROTOCOL` via `s.Environ()`) is intentionally not forwarded
9999+ yet; v0/v1 is sufficient.
4410045101### Push hooks (`hooks.go`, sandboxed via kefka)
46102