worklyn / zukapublic
Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.
Get a copy:
git clone https://zuka.worklyn.com/worklyn/zuka.git
| 1 | {% hero tone="ink" %} |
| 2 | # zuka |
| 3 | |
| 4 | Anyone can make software now. |
| 5 | Keeping it somewhere safe shouldn't be the hard part. |
| 6 | |
| 7 | zuka is git hosting for a person and their agent — one small binary that keeps |
| 8 | the code safe, shows it as a website, and stays out of the way. |
| 9 | |
| 10 | [See it live](https://zuka.worklyn.com) [Start with why](#why) [Run your own](#install-onto-a-host) |
| 11 | {% /hero %} |
| 12 | |
| 13 | {% callout type="note" title="This page is zuka" %} |
| 14 | You are reading a README, served as a website by the thing it describes. zuka |
| 15 | hosts its own source and ships itself: every push builds, tests and deploys the |
| 16 | server that will serve the next push. |
| 17 | {% /callout %} |
| 18 | |
| 19 | ## Why |
| 20 | |
| 21 | Agents changed who gets to make software. A person who has never opened a |
| 22 | terminal can describe a thing — a booking page, a family recipe book, a tool |
| 23 | for their shop — and an agent will build it. The code part stopped being the |
| 24 | barrier. |
| 25 | |
| 26 | Then the code needs a home, and the old world returns: create an account and |
| 27 | an organisation and a team, mint a deploy key, open a pull request, satisfy a |
| 28 | review, appease a YAML file. Every one of those steps was designed for |
| 29 | professional software teams. Every one of them is a closed door to exactly the |
| 30 | person the agent just let in. |
| 31 | |
| 32 | zuka exists because of one belief: **the machinery around code should not |
| 33 | decide who gets to make things.** Keep the work safe. Show it in a form a |
| 34 | person can actually read. Let the agent handle the technical part — it is |
| 35 | good at that. |
| 36 | |
| 37 | ## How |
| 38 | |
| 39 | Three decisions carry the whole product. |
| 40 | |
| 41 | {% cards %} |
| 42 | {% card title="git does the git" %} |
| 43 | Agents already speak git fluently. Code moves over the ordinary git wire |
| 44 | protocol, HTTP or SSH — nothing proprietary, nothing to relearn, nothing that |
| 45 | breaks when the agent changes. |
| 46 | {% /card %} |
| 47 | {% card title="Everything else only asks and reads" %} |
| 48 | The API and MCP create repositories, register keys, read files, report CI. |
| 49 | None of them can change code. The one write path is `git push`, so there is |
| 50 | exactly one concurrency model, one merge story, one source of truth — git's. |
| 51 | {% /card %} |
| 52 | {% card title="People get a website, not a wall of files" %} |
| 53 | The front page of a repository is its README, rendered as a page. The words |
| 54 | say Files, History and Branches. A wrong link gets a sentence and a way home, |
| 55 | not machine JSON. |
| 56 | {% /card %} |
| 57 | {% /cards %} |
| 58 | |
| 59 | And one refusal, held on purpose: **no organisations, no teams, no pull |
| 60 | requests, no review queues, no issues.** Those are coordination tools for |
| 61 | large groups of humans. One person and their agent don't need them, and every |
| 62 | one of them is a room a newcomer gets lost in. The whole model is accounts, |
| 63 | repositories, refs, keys and runs. |
| 64 | |
| 65 | Security follows the same shape rather than being bolted on: anonymous means |
| 66 | read-only and passes through exactly one door in the code; a private |
| 67 | repository is indistinguishable from an absent one; nothing on the browser |
| 68 | surface can execute a script at all. The detail lives in |
| 69 | [Security](#security), each claim with a test that fails if it regresses. |
| 70 | |
| 71 | ## What |
| 72 | |
| 73 | One Rust binary, no database, no Docker, no external services. It serves: |
| 74 | |
| 75 | - **Bare git repositories** over HTTP and SSH — clone, push, `git archive` |
| 76 | - **A browser** where every repository is a small website ([how that works](#your-readme-is-the-website)) |
| 77 | - **A REST API** for everything git can't do: create, configure, read, search |
| 78 | - **MCP** so agents drive it natively ([the tools](#for-agents--mcp)) |
| 79 | - **CI** from a five-line file in the repository |
| 80 | - **Multi-tenant isolation** — every account in its own container, when you want it |
| 81 | |
| 82 | The same binary is the server, the CLI and the setup tool. A stranger can drop |
| 83 | it on a VM and have git hosting with TLS in minutes. |
| 84 | |
| 85 | --- |
| 86 | |
| 87 | ## Your README is the website |
| 88 | |
| 89 | Open a repository in a browser and you don't get a wall of filenames — you get |
| 90 | the README, rendered as a page, the way [Markdoc](https://markdoc.dev/) turns |
| 91 | Stripe's doc files into stripe.com/docs. The file listing is one tab away, not |
| 92 | the thing a visitor has to squint at first. |
| 93 | |
| 94 | The viewer is built for people who have never used a code host: the tabs say |
| 95 | **Overview · Files · History · Branches** instead of tree, log and refs; every |
| 96 | tab remembers which branch you were on; history reads like a chat feed with a |
| 97 | coloured initial for each author; statuses say *Added*, *Changed*, *Removed*. |
| 98 | |
| 99 | Plain markdown already works. For readmes that want to be front pages, there's |
| 100 | a small dialect — a subset of Markdoc's syntax. A tag sits alone on its own |
| 101 | line; everything between tags is ordinary markdown: |
| 102 | |
| 103 | ``` |
| 104 | {% hero tone="grape" %} |
| 105 | # Big friendly title |
| 106 | One-line pitch under it. |
| 107 | |
| 108 | [Primary button](docs/start.md) [Second button](#below) |
| 109 | {% /hero %} |
| 110 | |
| 111 | {% big tone="sun" %} |
| 112 | A statement in display type. |
| 113 | {% /big %} |
| 114 | |
| 115 | {% band tone="mint" %} |
| 116 | ## A full-width painted stripe |
| 117 | Bands turn a README into a landing page. Text keeps a readable |
| 118 | measure; the colour reaches the edges of the screen. |
| 119 | {% /band %} |
| 120 | |
| 121 | {% callout type="tip" title="Good to know" %} |
| 122 | Callouts come as note, tip, warn and danger. |
| 123 | {% /callout %} |
| 124 | |
| 125 | {% cards columns="2" %} |
| 126 | {% card tone="sky" title="Left" %} |
| 127 | Feature one. |
| 128 | {% /card %} |
| 129 | {% card tone="rose" title="Right" %} |
| 130 | Feature two. |
| 131 | {% /card %} |
| 132 | {% /cards %} |
| 133 | |
| 134 | {% details summary="The long table nobody reads twice" %} |
| 135 | …hidden until tapped, no JavaScript involved… |
| 136 | {% /details %} |
| 137 | ``` |
| 138 | |
| 139 | The rules, all of them: |
| 140 | |
| 141 | - The front page is full-width: prose keeps a readable measure, heroes and |
| 142 | bands paint edge to edge, and everything folds down cleanly on a phone. |
| 143 | - Six tones — `sun, mint, sky, rose, grape, ink` — work in light and dark |
| 144 | mode. Attributes are allow-listed: a document picks from the palette, it |
| 145 | cannot invent styles, so expressiveness costs no security. |
| 146 | - A tag line starts at column zero. Indented or fenced tags are shown, not run — |
| 147 | which is how this README quotes them. |
| 148 | - Unknown tags vanish and their content stays, so a document written for a |
| 149 | richer engine degrades to its words, never to tag soup. |
| 150 | - An unclosed tag is closed for you at the end. A typo can't eat the page. |
| 151 | - Relative links work: `[guide](docs/guide.md)` opens that file rendered as a |
| 152 | page, `` serves the image, `[src](src/)` opens the |
| 153 | listing. A folder of markdown becomes a small site with zero configuration. |
| 154 | - Headings get anchor ids, so `[jump](#why)` works — this page uses them. |
| 155 | - Any `.md` file renders as a document; add `?plain=1` to see the raw text. |
| 156 | - Every repository answers `llms.txt` — yours verbatim if committed, a |
| 157 | generated one if not — so agents get a front door as readable as the human |
| 158 | one. The site root's `/llms.txt` belongs to the featured repository. |
| 159 | |
| 160 | And because a README is written by anyone who can push — on a public |
| 161 | repository, a stranger — the whole surface stays paranoid: raw HTML is |
| 162 | dropped, link schemes pass an allow-list, every interpolated value is escaped, |
| 163 | and the page ships a CSP with **no script source at all**. The dialect adds |
| 164 | wrappers, not execution. |
| 165 | |
| 166 | --- |
| 167 | |
| 168 | ## For agents — MCP |
| 169 | |
| 170 | ``` |
| 171 | POST /mcp JSON-RPC 2.0: initialize · ping · tools/list · tools/call |
| 172 | ``` |
| 173 | |
| 174 | The intended session: **`repo_create` → `key_add` → plain |
| 175 | `git clone`/`commit`/`push` → `run_list`**. There is no tool that writes to a |
| 176 | repository, and `initialize` says so, so a model doesn't go hunting for one. |
| 177 | |
| 178 | A tool failure that's the caller's fault comes back as a tool result with |
| 179 | `isError: true`, not a JSON-RPC error — the model should see it and adapt, not |
| 180 | have the call look broken. |
| 181 | |
| 182 | {% details summary="The 18 tools" %} |
| 183 | |
| 184 | **Administration** — the part git can't do |
| 185 | | Tool | | |
| 186 | |---|---| |
| 187 | | `repo_create` | Create a repository. Returns both clone URLs, and `web_url` when public. | |
| 188 | | `repo_list` `repo_get` `repo_delete` | | |
| 189 | | `key_add` `key_list` `key_remove` | Register an SSH key so the agent can then use git. | |
| 190 | |
| 191 | **Discovery** — read without cloning |
| 192 | | Tool | | |
| 193 | |---|---| |
| 194 | | `ref_list` | Branches, tags, notes with tip commits. | |
| 195 | | `tree_list` | List a directory. | |
| 196 | | `file_read` | Read a text file. | |
| 197 | | `search` | Fixed-string search across the tree. | |
| 198 | | `commit_log` `commit_get` | History; optionally path-filtered. | |
| 199 | | `compare` | Diff from the merge base. | |
| 200 | | `blame` | Who last changed each line. | |
| 201 | |
| 202 | **CI** |
| 203 | | Tool | | |
| 204 | |---|---| |
| 205 | | `run_list` `run_get` `run_logs` | Did the build pass, and why not. | |
| 206 | |
| 207 | {% /details %} |
| 208 | |
| 209 | --- |
| 210 | |
| 211 | ## Quickstart |
| 212 | |
| 213 | Needs `git` ≥ 2.41 on the host. |
| 214 | |
| 215 | ```sh |
| 216 | cargo build --release |
| 217 | export ZUKA_DATA_DIR=/tmp/zuka && mkdir -p "$ZUKA_DATA_DIR" |
| 218 | |
| 219 | TOKEN=$(./target/release/zuka token alice --scopes admin) # shown once |
| 220 | ./target/release/zuka |
| 221 | # [boot] listening on http://127.0.0.1:8790 |
| 222 | # [ssh] listening on ssh://127.0.0.1:2222 |
| 223 | ``` |
| 224 | |
| 225 | **Create and push** |
| 226 | |
| 227 | ```sh |
| 228 | curl -X POST localhost:8790/v1/repos \ |
| 229 | -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \ |
| 230 | -d '{"name":"site","visibility":"public"}' |
| 231 | |
| 232 | git clone "http://x-access-token:$TOKEN@127.0.0.1:8790/alice/site.git" |
| 233 | cd site && echo hello > README.md |
| 234 | git add . && git commit -m "first" && git push origin HEAD:refs/heads/main |
| 235 | ``` |
| 236 | |
| 237 | The git username is ignored; the password is your token. `visibility` defaults |
| 238 | to `private`; a public repository is clonable and browsable by anyone, |
| 239 | writable by no one anonymous. Open `http://127.0.0.1:8790/alice/site` and |
| 240 | there's your website. |
| 241 | |
| 242 | **Use SSH instead** |
| 243 | |
| 244 | ```sh |
| 245 | ./target/release/zuka key alice ~/.ssh/id_ed25519.pub --title laptop |
| 246 | git clone ssh://git@127.0.0.1:2222/alice/site.git |
| 247 | ``` |
| 248 | |
| 249 | `--read-only` makes a key clone-only; `--repos a,b` confines it. |
| 250 | |
| 251 | **Read without cloning** |
| 252 | |
| 253 | ```sh |
| 254 | A="Authorization: Bearer $TOKEN"; R=localhost:8790/v1/repos/alice/site |
| 255 | curl -H "$A" $R/refs |
| 256 | curl -H "$A" "$R/tree/src?ref=refs/heads/main" |
| 257 | curl -H "$A" "$R/raw/README.md" |
| 258 | curl -H "$A" "$R/search?q=TODO" |
| 259 | curl -H "$A" "$R/blame/src/main.rs" |
| 260 | curl -H "$A" "$R/compare?base=<sha>&head=<sha>" |
| 261 | ``` |
| 262 | |
| 263 | On `/v1` the ref is always a **query parameter**, never a path segment — |
| 264 | `feature/login` and `src/main.rs` both contain slashes, so `/raw/{ref}/{path}` |
| 265 | has no unique parse. The browser URLs accept the ambiguity for the sake of |
| 266 | readable links; the API does not. |
| 267 | |
| 268 | **Turn on CI** |
| 269 | |
| 270 | ```sh |
| 271 | ZUKA_CI_ENABLED=1 ./target/release/zuka |
| 272 | ``` |
| 273 | |
| 274 | ```toml |
| 275 | # .zuka.toml, in the repository |
| 276 | [run] |
| 277 | steps = ["cargo fmt --check", "cargo test"] |
| 278 | timeout_secs = 600 |
| 279 | branches = ["refs/heads/main"] # optional; omit to run everywhere |
| 280 | ``` |
| 281 | |
| 282 | Push, then `curl -H "$A" $R/runs` and `$R/runs/{id}/logs`. |
| 283 | |
| 284 | --- |
| 285 | |
| 286 | ## Install onto a host |
| 287 | |
| 288 | ```sh |
| 289 | sudo zuka setup --standalone --dry-run # prints every file it would write |
| 290 | sudo zuka setup --standalone |
| 291 | ``` |
| 292 | |
| 293 | It writes `/etc/zuka.env` and two systemd units — the service and maintenance |
| 294 | separately, because a long repack must not stall requests — then prints the |
| 295 | remaining commands rather than running them. Creating users and enabling units |
| 296 | are steps someone may reasonably want to do differently. |
| 297 | |
| 298 | **`--domain` is optional, and does one thing:** it adds a Caddyfile so Caddy |
| 299 | obtains and renews TLS for that name, and sets the public URLs advertised in |
| 300 | clone URLs. Without it you get a service on `127.0.0.1:8790` with no TLS, which |
| 301 | is right behind an existing proxy or for a local trial. |
| 302 | |
| 303 | ```sh |
| 304 | sudo zuka setup --standalone --domain git.example.com |
| 305 | ``` |
| 306 | |
| 307 | **`--isolated`** installs the multi-tenant shape: a control plane that holds no |
| 308 | repositories and gives every account its own Incus container. Code, CI and |
| 309 | objects for one account never touch another's filesystem. `--domain` is |
| 310 | optional here too and means exactly the same thing — the control plane is the |
| 311 | only public listener, so it is the only thing a certificate is for. |
| 312 | |
| 313 | ```sh |
| 314 | sudo zuka setup --isolated |
| 315 | curl -X POST localhost:8790/v1/accounts/alice # returns immediately |
| 316 | ``` |
| 317 | |
| 318 | Set a public URL if you have one: a tenant is only reachable *through* the |
| 319 | control plane, so left to itself it advertises its own container address and |
| 320 | hands callers a clone URL they cannot use. |
| 321 | |
| 322 | Provisioning never blocks a request. The account records intent and a |
| 323 | reconciler drives it to `ready`; a request arriving early gets `429` with a |
| 324 | `Retry-After`. Tenants are stopped when idle and started again on demand. |
| 325 | |
| 326 | {% callout type="warn" title="Static linking is not optional in --isolated" %} |
| 327 | The tenant binary is pushed into a container whose C library is not the host's: |
| 328 | |
| 329 | ```sh |
| 330 | cargo build --release --target x86_64-unknown-linux-musl |
| 331 | ``` |
| 332 | {% /callout %} |
| 333 | |
| 334 | --- |
| 335 | |
| 336 | ## API |
| 337 | |
| 338 | Full contract: [`openapi.json`](openapi.json), also served at `/openapi.json`. |
| 339 | |
| 340 | | | | |
| 341 | |---|---| |
| 342 | | `GET /healthz` | Mode, version, disk headroom, CI state. `503` below the reserve. | |
| 343 | | `GET /openapi.json` | This service's OpenAPI 3.1 document. | |
| 344 | | `GET /v1/account` | Identity, scopes, storage usage and limits. | |
| 345 | | `GET POST /v1/repos` | List, create (optionally `import_url`, `visibility`). | |
| 346 | | `GET PATCH DELETE /v1/repos/{account}/{repo}` | Read, update settings, soft-delete. | |
| 347 | | `GET .../refs?type=` | Branches, tags, notes. | |
| 348 | | `GET .../tree/{path}?ref=` | Directory listing. | |
| 349 | | `GET .../raw/{path}?ref=` | File bytes. | |
| 350 | | `GET .../commits?ref=&path=&limit=` | Log. | |
| 351 | | `GET .../commits/{sha}` | One commit and its changed paths. | |
| 352 | | `GET .../compare?base=&head=` | Diff from the merge base. | |
| 353 | | `GET .../search?q=&path=&ref=` | Content search. | |
| 354 | | `GET .../blame/{path}?ref=` | Line attribution. | |
| 355 | | `GET POST .../runs` · `GET PATCH .../runs/{id}` · `GET .../runs/{id}/logs` | CI. | |
| 356 | | `GET POST /v1/keys` · `DELETE /v1/keys/{id}` | SSH keys. | |
| 357 | | `POST /mcp` | MCP JSON-RPC. | |
| 358 | | `/{account}/{repo}.git/*` | Git smart HTTP. Protocol v2. | |
| 359 | | `ssh://git@host/{account}/{repo}.git` | Git over SSH, plus `git archive --remote`. | |
| 360 | |
| 361 | The browser lives on the same origin, read-only, no credential required for |
| 362 | public repositories: `/{account}/{repo}` (the clone URL minus `.git`), plus |
| 363 | `/tree/{ref}/{path}`, `/blob/{ref}/{path}` (`?plain=1` for raw text), |
| 364 | `/raw/{ref}/{path}` (download), `/commits/{ref}` (`?from={sha}` pages older), |
| 365 | `/commit/{sha}` and `/refs`. `/` redirects to the repository named by |
| 366 | `ZUKA_HOME_REPO`. |
| 367 | |
| 368 | Errors on `/v1` and `/mcp` are RFC 9457 `application/problem+json` with a |
| 369 | stable `type` URI — discriminate on `type`, never on `detail`. The git paths |
| 370 | answer in `text/plain`, because git prints the body straight at the user. The |
| 371 | browser pages answer a missing page in HTML, because a person tapped that link. |
| 372 | |
| 373 | Lists return `{items, truncated}`. There is no cursor, and none is claimed. |
| 374 | |
| 375 | ### CLI |
| 376 | |
| 377 | ``` |
| 378 | zuka serve |
| 379 | zuka setup --standalone|--isolated install onto this host [--domain d] [--dry-run] |
| 380 | zuka jobs maintenance daemon (own service unit) |
| 381 | zuka gc | sweep | fsck [--repair] one-shot maintenance |
| 382 | zuka token <account> [--scopes s] [--repos r] [--days n] [--no-expiry] |
| 383 | zuka key <account> <path.pub> [--title t] [--read-only] [--repos r] |
| 384 | zuka version |
| 385 | ``` |
| 386 | |
| 387 | Scopes: `repo:read`, `repo:write`, `admin`, `ci`. Tokens expire in 90 days |
| 388 | unless you pass `--days` or opt out with `--no-expiry`, which warns. |
| 389 | Credentials added while the server runs take effect without a restart. |
| 390 | |
| 391 | `fsck` exits non-zero on an orphan so a timer notices. It reports a repository |
| 392 | with no metadata but never deletes one — repair only removes records that |
| 393 | describe nothing. |
| 394 | |
| 395 | --- |
| 396 | |
| 397 | ## Configuration |
| 398 | |
| 399 | Everything has a working default; nothing is required. |
| 400 | |
| 401 | {% details summary="All settings" %} |
| 402 | |
| 403 | | Variable | Default | | |
| 404 | |---|---|---| |
| 405 | | `ZUKA_BIND` | `127.0.0.1:8790` | HTTP listener. | |
| 406 | | `ZUKA_SSH_BIND` | `127.0.0.1:2222` | SSH listener; `off` disables. | |
| 407 | | `ZUKA_DATA_DIR` | `/var/lib/zuka` | Everything on disk. | |
| 408 | | `ZUKA_PUBLIC_URL` / `_SSH` | derived | Advertised clone URLs. | |
| 409 | | `ZUKA_HOME_REPO` | — | `account/repo` featured at `/` in the browser. | |
| 410 | | `ZUKA_PROBLEM_BASE` | `https://zuka.dev/problems` | Point error `type` URIs at your own docs. | |
| 411 | | **Limits** | | | |
| 412 | | `ZUKA_MAX_REPOS_PER_ACCOUNT` | `100` | `0` disables. | |
| 413 | | `ZUKA_MAX_REPO_MB` / `_ACCOUNT_MB` | `2048` / `10240` | `0` disables. | |
| 414 | | `ZUKA_RATE_PER_MINUTE` | `600` | Per credential, on `/v1` and `/mcp`. `0` disables. | |
| 415 | | `ZUKA_MAX_PACK_MB` | `512` | Largest push. Also sets `receive.maxInputSize`. | |
| 416 | | `ZUKA_MAX_BLOB_MB` | `32` | Largest blob served inline. | |
| 417 | | `ZUKA_MAX_BODY_MB` | `10` | Largest JSON body. | |
| 418 | | `ZUKA_PAGE_LIMIT` / `_MAX_PAGE_LIMIT` | `50` / `200` | | |
| 419 | | `ZUKA_MAX_CONCURRENT_GIT` | `8` | Concurrent git subprocesses. | |
| 420 | | `ZUKA_DISK_RESERVE_MB` | `2048` | Below this, writes return `507`. | |
| 421 | | **CI** | | | |
| 422 | | `ZUKA_CI_ENABLED` | `0` | Off by default; see below. | |
| 423 | | `ZUKA_CI_TIMEOUT_SECS` | `600` | Ceiling. A spec may lower it, never raise it. | |
| 424 | | `ZUKA_CI_MAX_CONCURRENT` | `2` | | |
| 425 | | `ZUKA_CI_LOG_MB` | `8` | Output cap per run. | |
| 426 | | `ZUKA_CI_MEMORY_MB` | `2048` | `RLIMIT_AS`. `0` disables. | |
| 427 | | `ZUKA_CI_FILE_MB` | `2048` | `RLIMIT_FSIZE`. Independent of the memory limit. | |
| 428 | | `ZUKA_CI_MAX_PROCESSES` | `0` | `RLIMIT_NPROC` — per-UID, so only meaningful with a runner uid. | |
| 429 | | `ZUKA_CI_KEEP_RUNS` | `50` | Runs retained per repository. | |
| 430 | | **Maintenance** | | | |
| 431 | | `ZUKA_GC_INTERVAL_SECS` | `21600` | | |
| 432 | | `ZUKA_GC_PRUNE_GRACE` | `2.weeks.ago` | Objects younger than this are never pruned. | |
| 433 | | `ZUKA_DELETED_RETENTION_DAYS` | `7` | | |
| 434 | | **Import** | | | |
| 435 | | `ZUKA_IMPORT_ENABLED` | `1` | | |
| 436 | | `ZUKA_IMPORT_TIMEOUT_SECS` | `300` | | |
| 437 | | `ZUKA_IMPORT_ALLOW_PRIVATE` | `0` | Permit private/loopback sources. Only on a trusted network. | |
| 438 | | **Multi-tenant** | | | |
| 439 | | `ZUKA_MODE` | `standalone` | `standalone` · `control` · `tenant` | |
| 440 | | `ZUKA_OPERATOR` | — | Account allowed to manage accounts. Unset ⇒ refused for all. | |
| 441 | | `ZUKA_INCUS_IMAGE` | `images:debian/12` | Image for a new tenant. | |
| 442 | | `ZUKA_INCUS_NETWORK` | `incusbr0` | Network a tenant joins. | |
| 443 | | `ZUKA_TENANT_BINARY` | this binary | Pushed into each container; must be static. | |
| 444 | | `ZUKA_TENANT_PORT` | `8790` | Port a tenant listens on inside its container. | |
| 445 | | `ZUKA_CONTROL_PUBLIC_KEYS` | — | Tenant only. Comma-separated; more than one allows key rotation. | |
| 446 | | `ZUKA_PROXY_TIMEOUT_SECS` | `300` | Ceiling on one proxied request. | |
| 447 | |
| 448 | {% /details %} |
| 449 | |
| 450 | `ZUKA_ME_URL` is parsed and then **refuses to boot** — hosted auth isn't |
| 451 | implemented, and accepting a security-relevant setting while ignoring it is |
| 452 | worse than not accepting it. |
| 453 | |
| 454 | ### Maintenance |
| 455 | |
| 456 | Run `zuka jobs` as a second service unit — a long repack must not stall request |
| 457 | handling. |
| 458 | |
| 459 | ```ini |
| 460 | [Unit] |
| 461 | Description=zuka maintenance |
| 462 | After=zuka.service |
| 463 | [Service] |
| 464 | ExecStart=/usr/local/bin/zuka jobs |
| 465 | EnvironmentFile=/etc/zuka.env |
| 466 | User=zuka |
| 467 | Restart=always |
| 468 | [Install] |
| 469 | WantedBy=multi-user.target |
| 470 | ``` |
| 471 | |
| 472 | It garbage-collects (git's own auto-gc is disabled on every repo, so a repack |
| 473 | never fires *inside* a push), sweeps deleted repositories and old runs, and |
| 474 | fscks. |
| 475 | |
| 476 | What makes GC safe against a live clone is the **grace window**, not a lock — a |
| 477 | lock couldn't span the two processes. `upload-pack` can reference an object |
| 478 | between resolving and streaming it, so nothing younger than `GC_PRUNE_GRACE` is |
| 479 | pruned. A test clones a 12 MB repo while GC runs and checks the result byte for |
| 480 | byte. |
| 481 | |
| 482 | {% callout type="danger" title="Force-pushing does not delete" %} |
| 483 | A committed secret stays fetchable by sha until GC prunes it. Force-pushing |
| 484 | past it moves the ref, not the object. |
| 485 | {% /callout %} |
| 486 | |
| 487 | --- |
| 488 | |
| 489 | ## Security |
| 490 | |
| 491 | Each of these has a test that fails if it regresses. Several are here because |
| 492 | an earlier version got them wrong. |
| 493 | |
| 494 | **Names can't escape the data directory.** Ref names go through `gix-validate`, |
| 495 | never a hand-rolled check — `../../config` is a command-execution primitive via |
| 496 | `core.pager` and `core.sshCommand`. Tree paths reject any `.git` component |
| 497 | after Unicode normalisation, case folding, HFS-ignorable-codepoint stripping |
| 498 | and trailing dot/space removal. |
| 499 | |
| 500 | **Names are case-folded.** `Alice/Site` and `alice/site` are one resource. |
| 501 | Comparing the raw path segment once let a case-variant account reach another |
| 502 | account's repository on a case-insensitive filesystem while passing the |
| 503 | ownership check. |
| 504 | |
| 505 | **An unreadable repository is `404`, not `403`.** `403` enumerates private |
| 506 | repos. Confinement applies to destruction too: a token scoped to one repo |
| 507 | can't delete another. |
| 508 | |
| 509 | **Anonymous means read, and only through one door.** Exactly one function |
| 510 | grants anonymous access, it can only read, and a presented-but-rejected |
| 511 | credential stays rejected — an expired token never quietly degrades into an |
| 512 | anonymous visitor. The browser answers a private repository with the same page |
| 513 | as an absent one; the git wire answers both with a `401` challenge, so neither |
| 514 | surface can be used to enumerate names. |
| 515 | |
| 516 | **The viewer renders hostile input inert.** Every value is escaped, markdown |
| 517 | drops raw HTML and rewrites link schemes against an allow-list, and pages carry |
| 518 | a CSP with no script source — so anything that survived the escaping would |
| 519 | still have nowhere to execute. Raw file bytes are served as an attachment in a |
| 520 | CSP sandbox, never rendered on the origin. |
| 521 | |
| 522 | **Rewriting is allowed; protection is opt-in.** `rebase` and `--amend` are |
| 523 | ordinary git and blocking them protects nobody when there's one agent and no |
| 524 | reviewer — git's reflog is the recovery path. Set `protected_refs` on the |
| 525 | branch something deploys from; it's then enforced server-side over HTTP *and* |
| 526 | SSH, which matters because `--force` bypasses the client check. One Rust |
| 527 | function; the hook is a one-line `exec` into the binary. |
| 528 | |
| 529 | **SSH runs git and nothing else.** No shell, no pty, no subsystem. Three |
| 530 | services: `upload-pack`, `receive-pack`, `upload-archive`. The exec string is |
| 531 | parsed, never shelled, so `git-upload-pack '/a/b.git'; id` is a parse error. |
| 532 | The account comes from *which registered key matched*, never the SSH username. |
| 533 | Only `GIT_PROTOCOL` crosses into the child. |
| 534 | |
| 535 | **Only three endpoints route under `.git`.** The dumb protocol isn't served. A |
| 536 | duplicated `service` parameter is refused — this server would take the first |
| 537 | and the CGI the last, so a duplicate would let the authorization decision and |
| 538 | the running service disagree. |
| 539 | |
| 540 | **Import can't reach the local network.** The one place a caller-supplied URL |
| 541 | is fetched. Checked in two stages: shape (scheme, credentials) then *every |
| 542 | address the host resolves to* — checking the hostname alone is defeated by a |
| 543 | name that resolves to `169.254.169.254`. |
| 544 | |
| 545 | **Credentials are hashed, scoped, expiring and `0600`.** Token comparison has |
| 546 | no early return. Public keys match on raw bytes; `authorized_keys` option |
| 547 | prefixes like `command=` are refused. |
| 548 | |
| 549 | **Deletes are recoverable.** Soft-deleted to `tmp/deleted/`, swept on |
| 550 | retention. |
| 551 | |
| 552 | **Nothing buffers a pack.** |
| 553 | |
| 554 | ``` |
| 555 | before after |
| 556 | 200 MB push, server RSS ~360 MB 5 MB |
| 557 | 4 concurrent clones 1508 MB 5 MB |
| 558 | ``` |
| 559 | |
| 560 | The child is killed by **process group** on disconnect — `kill_on_drop` reaches |
| 561 | only the direct child, and `pack-objects` is a grandchild that otherwise kept |
| 562 | running for seconds after the client left. |
| 563 | |
| 564 | ### Multi-tenant isolation |
| 565 | |
| 566 | In `--isolated`, each account gets its own Incus container, unprivileged and |
| 567 | with nesting off. The control plane holds no repositories: a push through it |
| 568 | lands in the account's container and nowhere on the host. |
| 569 | |
| 570 | A tenant is told who is calling by an **Ed25519 assertion the control plane |
| 571 | signs**, not a shared secret. That matters because a tenant runs the account's |
| 572 | own CI, which is arbitrary code — anything symmetric in there would forge every |
| 573 | other tenant. The private key never leaves the control plane; tenants hold only |
| 574 | the public half, which forges nothing. |
| 575 | |
| 576 | The assertion authenticates one request, not an identity: single-use nonce, |
| 577 | 30-second window, bound to the method and path. It is deliberately *not* bound |
| 578 | to the body, because a push is a multi-gigabyte pack the proxy streams — the |
| 579 | nonce is what stops replay. |
| 580 | |
| 581 | Verified on a real host rather than asserted, in |
| 582 | [`infra/README.md`](../../infra/README.md): CI inside a tenant reports |
| 583 | `uname -n` = `zuka-alice`, and a second account gets `404` on the first's |
| 584 | repository and an empty repo list. |
| 585 | |
| 586 | ### CI is not a security boundary in standalone |
| 587 | |
| 588 | Steps run as the service user with rlimits, a scrubbed environment, a |
| 589 | process-group kill on timeout and a bounded log — but **no container and no |
| 590 | network namespace**. Anyone who can push can run code as that user. CI is off |
| 591 | by default and the boot log says so. |
| 592 | |
| 593 | A step allowlist was considered and rejected: `sh -c` defeats it in one |
| 594 | character. Enforcement is at the OS or it isn't enforcement. Multi-tenant |
| 595 | isolation is what `--isolated` is for. |
| 596 | |
| 597 | --- |
| 598 | |
| 599 | ## Renaming it |
| 600 | |
| 601 | The name lives in exactly one file. `src/brand.rs` derives the env prefix, data |
| 602 | directory, problem-type URIs, auth realm, git config namespace, CI filename and |
| 603 | hook shim from `CARGO_PKG_NAME`. |
| 604 | |
| 605 | Renaming is one line in `Cargo.toml` plus the `openapi.json` title — verified |
| 606 | by actually doing it. A unit test walks `src/` and fails if the name appears as |
| 607 | a literal anywhere outside `brand.rs`; it has already caught a hardcoded auth |
| 608 | realm, and it is the reason even the browser viewer's stylesheet URL is derived. |
| 609 | |
| 610 | --- |
| 611 | |
| 612 | ## Development |
| 613 | |
| 614 | ```sh |
| 615 | cargo fmt --check |
| 616 | cargo clippy --all-targets -- -D warnings |
| 617 | cargo test # 301 unit tests |
| 618 | deno task test:live # 69 live tests: spawns the binary, drives real git and ssh |
| 619 | ``` |
| 620 | |
| 621 | The live suite is the real proof — unit tests cover units, but only a real |
| 622 | clone and push exercise the wire protocol. It runs in CI rather than |
| 623 | self-skipping there. The same four commands are the CI gate zuka runs on |
| 624 | itself before deploying itself. |
| 625 | |
| 626 | ``` |
| 627 | src/ |
| 628 | ├── brand.rs the one place the product name appears |
| 629 | ├── config.rs Config::from_env(); fails boot on a bad value |
| 630 | ├── error.rs Error -> RFC 9457 problem+json |
| 631 | ├── core/ domain operations shared by every facade |
| 632 | ├── git/ |
| 633 | │ ├── exec.rs the only place this service invokes git |
| 634 | │ ├── validate.rs security controls: names, refs, paths |
| 635 | │ ├── store.rs repositories, hooks, ref rules |
| 636 | │ ├── discover.rs the read core, shared by REST, MCP and the browser |
| 637 | │ ├── transport.rs streaming CGI bridge |
| 638 | │ └── import.rs SSRF-guarded cloning |
| 639 | ├── http/ routing, limits, responses, auth, rate limiting |
| 640 | ├── api/ REST facade |
| 641 | ├── mcp/ MCP facade |
| 642 | ├── web/ the browser viewer: escaping, the tag dialect, the pages |
| 643 | ├── ssh/ SSH server and exec-command parser |
| 644 | ├── ci/ spec, run store, runner, executor |
| 645 | ├── control/ accounts, provisioning, reconciler, streaming proxy |
| 646 | ├── setup.rs systemd units, Caddyfile, environment file |
| 647 | ├── jobs/ gc, sweep, fsck |
| 648 | ├── account/ identity, tokens, SSH keys |
| 649 | └── store/ metadata, quotas, change-aware file cache |
| 650 | ``` |
| 651 | |
| 652 | Two rules worth knowing before changing anything: |
| 653 | |
| 654 | - **git is the only writer.** No endpoint or tool modifies repository contents. |
| 655 | - **Facades translate; they don't decide.** Authorization lives on `Identity`, |
| 656 | git rules in `git/`, shared operations in `core/`. `api/` and `mcp/` reshape |
| 657 | payloads and nothing else — they had drifted copies of repo create and delete |
| 658 | once, and those copies had already diverged in four ways. |
| 659 | |
| 660 | --- |
| 661 | |
| 662 | ## The name |
| 663 | |
| 664 | Chosen for the sound of it rather than the meaning: two syllables, a rare |
| 665 | opening consonant, one plausible spelling after hearing it once. It sits near |
| 666 | Swahili *kuzuka* — to surface, to emerge — without claiming to be it. Which |
| 667 | is, in the end, the why in one word: things people make should surface. |
| 668 | |
| 669 | More: [`SPEC.md`](SPEC.md) · [`ROADMAP.md`](ROADMAP.md) · [`AGENTS.md`](AGENTS.md) |