# rust_nugitea A tokio/Rust rewrite of the sibling Go `nugitea` implementation, one directory up — but split into two tiers the way GitLab splits the Rails app from Gitaly, instead of the single-process design Gitea (and the Go version) use. That split is the point: it's what lets the app tier scale out horizontally without needing shared/replicated filesystem storage. ``` git client --HTTP/SSH--> [nugitea: app tier] --internal API--> [nugitea-storaged: storage tier] httpgit, sshgit, owns repo-root disk, GraphQL API, runs git subprocesses, mirror scheduler, installs hooks Authorizer (auth seam) [web: Nuxt frontend] --/graphql--> [nugitea: app tier] isomorphic SSR + client-side routing/hydration, no git protocol knowledge at all ``` The app tier never touches a local git path or subprocess — it only knows repo *paths* (`owner/name`, or `org/sub-org/.../name` under nested orgs) and proxies git protocol bytes to the storage tier. The storage tier trusts its caller (no auth of its own), the same way Gitaly trusts the Rails app tier; `Authorizer` checks happen once, at the app tier, before a request is ever proxied. Repos have owners — users and (optionally nested) orgs — but there's still no login, so nothing enforces who can do what yet: just clone, push, mirror, and a minimal read-only file browser. The browsing UI is a third, independent piece: a Nuxt.js app (`web/`) that speaks only GraphQL to the app tier's `/graphql` endpoint. It doesn't know git exists — it renders whatever the schema gives it, isomorphically (server-rendered on first load, then hydrated for client-side routing). Three deployables total, always run separately — there's no embedded single-process mode: - **`nugitea` (app tier)**: `httpgit.rs` (smart-HTTP) and `sshgit.rs` (SSH) are thin reverse proxies — resolve the repo path against the accounts directory (case-insensitively, like GitHub), check `Authorizer`, forward to the storage tier. `graphql.rs` exposes a GitHub-compatible GraphQL API (`repository(owner:, name:)`, `repositoryOwner`/`user`/`organization(login:)`, `resource(url:)`, `object(expression: "ref:path") { ... on Tree | ... on Blob }`) — see that file's doc comment for the full schema and the one extension (nested orgs). App-tier state lives in `--state-dir`: `accounts.json` (users, orgs, repo ownership — see `accounts/`) and `mirrors.json` (mirror schedule bookkeeping). The mirror scheduler (`mirror/`) lives here too, while the actual `git fetch`/`git push --mirror` calls are proxied through `storage_client.rs`. - **`nugitea-storaged` (storage tier)**: owns the repo-root disk (`storage.rs`), installs hooks, and exposes two internal listeners: - an HTTP API (`storage_server.rs`) for everything that's naturally request-then-response — ref advertisement, the HTTP transport's `--stateless-rpc` upload-pack/receive-pack, repo create/list/exists, tree/blob/branch/file-list reads for the GraphQL layer, and the generic `run_git` used by mirror sync. - a raw TCP relay (`git_exec_tcp.rs`) just for the SSH transport's interactive git protocol. SSH-driven `git upload-pack`/`receive-pack` write a ref advertisement to stdout *before* reading anything from stdin, with both directions live simultaneously — a duplex pattern HTTP/1.1 request/response framing doesn't reliably support. A plain TCP socket does, the same shape as talking to a local subprocess's pipes, just across a network hop. - **`web` (Nuxt frontend)**: pages under `web/app/pages/` — a repo-root redirect to the default branch, a tree browser, a blob viewer — plus a `SearchBox.vue` component (press `/` to open, fetches `repository.files(ref)` once, filters client-side, no server round-trip per keystroke). No GraphQL client library (Apollo/urql); just `$fetch` posting raw query strings via `web/app/composables/graphql.ts`, matching this project's general preference for the minimal tool over a heavier abstraction. SSR and post-hydration browser fetches both hit the app tier's `/graphql` — SSR resolves it container-to-container (`NUXT_GRAPHQL_URL`), the browser resolves it via whatever address the host can reach (`NUXT_PUBLIC_GRAPHQL_URL`). `names.rs` holds the one shared, pure (no I/O) repo-path validation and wire-form parsing used by both Rust tiers. On disk, a repo lives at `//.git`. `accounts/` models users, orgs (optionally nested, GitLab-style), org membership and base permissions, nested teams, custom repo roles, enterprises (including managed/EMU ones), blocks, and role grants — one model covering how Gitea, GitHub, and GitLab arrange them (see its doc comment for the comparison). Account and repo ownership are live — every repo path resolves through it — but memberships and grants aren't enforced yet, since nothing logs in. ## Running it: three processes, always `nugitea-storaged` (owns the disk), `nugitea` (speaks git + serves GraphQL), and `web` (Nuxt frontend) are always separate processes, even on one machine. Two ways to run them: ### Docker Compose (recommended for trying it out) ```sh docker compose up --build ``` This builds the two Rust binaries into one image (`Dockerfile`) and the Nuxt app into a separate image (`web/Dockerfile`), then starts all three services (`docker-compose.yml`) wired together on the compose-internal network. Published ports: `web`'s `3000` (the browsing UI), the app tier's `3080` (HTTP git + GraphQL) and `2222` (SSH), and `storaged`'s `9080` (HTTP API) and `9081` (git-exec TCP relay). `storaged`'s API has no auth of its own (it trusts its caller — the app tier's `Authorizer` checks happen on the other side of the proxy), so publishing it means anyone who can reach those ports can read or write any repo directly, without going through the app tier at all. Fine for local or otherwise trusted-network use; drop the `storaged` service's `ports:` block in `docker-compose.yml` before exposing this any more widely, so it's only reachable from the `app` service over the compose-internal network. Repo data and mirror state persist in named volumes (`nugitea-data`, `nugitea-state`) across restarts. Once it's up: ```sh docker compose exec app nugitea user create alice docker compose exec app nugitea repo create alice/demo docker compose exec app nugitea org create acme docker compose exec app nugitea org create acme/platform # a nested org docker compose exec app nugitea repo create acme/platform/api --visibility internal git clone http://localhost:3080/alice/demo.git git clone ssh://localhost:2222/acme/platform/api.git # push something, then browse it: open http://localhost:3000/alice/demo # or just visit it in a browser ``` Admin commands run inside the `app` container because that's where `accounts.json` lives (compose sets `NUGITEA_STATE_DIR` and `NUGITEA_STORAGE` there, so no flags are needed). `org create` and `repo create` take `--visibility public|internal|private` (default `public`); a child can't be more visible than its parent. There are no fixtures baked into the image — a fresh instance starts with zero repos and an empty `/repos` page. `scripts/seed-fixtures.sh` creates an org `demo` with a nested org `demo/tools` and a small repo in each, the same way a real client would — `nugitea org/repo create` followed by an actual `git commit`/push, not a shortcut that writes to the storage tier's disk directly — and is safe to re-run (it skips anything that already exists): ```sh scripts/seed-fixtures.sh http://localhost:3080 open http://localhost:3000/repos ``` ### Building and running directly with cargo + npm ```sh cargo build --release # storage tier — owns the disk ./target/release/nugitea-storaged serve --http 127.0.0.1:9080 --tcp 127.0.0.1:9081 --repo-root ./data # app tier — speaks git to the outside world and serves GraphQL ./target/release/nugitea serve --http :3080 --ssh :2222 \ --storage http://127.0.0.1:9080 --storage-tcp 127.0.0.1:9081 --state-dir ./state # admin commands read/write the same state dir as `serve` export NUGITEA_STATE_DIR=./state NUGITEA_STORAGE=http://127.0.0.1:9080 ./target/release/nugitea user create alice ./target/release/nugitea repo create alice/demo git clone http://localhost:3080/alice/demo.git git clone ssh://localhost:2222/alice/demo.git ./target/release/nugitea mirror add-pull --interval 5m alice/demo https://example.com/some/repo.git ./target/release/nugitea mirror add-push --interval 5m alice/demo git@example.com:backup/demo.git # seed demo content (see above) NUGITEA=./target/release/nugitea scripts/seed-fixtures.sh # frontend — a separate process, run from web/ cd web && npm install NUXT_GRAPHQL_URL=http://127.0.0.1:3080/graphql \ NUXT_PUBLIC_GRAPHQL_URL=http://localhost:3080/graphql \ npm run dev ``` Once something's been pushed, browse it at `http://localhost:3000/alice/demo` (or the Nuxt dev server's own port, `3001` by default) — that redirects to the default branch (`main` if it exists, else `master`, else whatever branch sorts first) and lets you walk the tree, view file contents, and press `/` to search the whole repo's file list in real time. URLs follow GitHub's shape (`/owner/repo/tree//`, `/owner/repo/blob/...`), with the owner part growing a segment per nested org; `/` lists an owner's repos and sub-orgs. The GraphQL API itself has an interactive GraphiQL page at `GET http://localhost:3080/graphql`. Env var equivalents for the Rust CLI: `NUGITEA_STORAGE` / `NUGITEA_STORAGE_TCP` (app tier's `--storage`/`--storage-tcp`), `NUGITEA_STATE_DIR` (app tier's `--state-dir`), `NUGITEA_REPO_ROOT` (storage tier's `repo`/`hook` subcommands). `nugitea-storaged repo create/list` also works directly on the storage node, bypassing the HTTP API, for operators logged in there — but a repo created that way has no entry in `accounts.json`, so the app tier won't serve it until one is added. Upgrading from before repos had owners: flat `/.git` repos aren't reachable any more (a repo path needs an owner). Move each one to `//.git` and create the owner and repo entries with `nugitea user/org create`; `repo create` fails if the directory already exists, so add the `accounts.json` entry before moving the repo in place. Durations accept a single `s`/`m`/`h` suffix (e.g. `30s`, `5m`, `2h`) — a deliberately smaller subset of Go's duration syntax. ## Development tooling - **Rust**: `cargo fmt` (configured by `rustfmt.toml`), `cargo clippy --all-targets`, `cargo test`. - **Frontend** (from `web/`): `npm run typecheck` (vue-tsc over the Nuxt app — TypeScript is pinned to 6.x, since 7's native compiler doesn't expose the JS API vue-tsc builds on), `npm run format` / `format:check` (Prettier, `.prettierrc.json`). - **GraphQL**: `web/schema.graphql` is the app tier's schema as SDL, checked in so editor tooling can autocomplete and validate the `/* GraphQL */`-tagged query strings in `web/app/` (config in `web/graphql.config.yml`). Regenerate it after changing `graphql.rs` with `cargo run --bin nugitea -- graphql-schema > web/schema.graphql`; a `cargo test` fails if it's stale. - **VS Code**: the repo root's `.vscode/` recommends rust-analyzer, Vue (Volar), Prettier, and the GraphQL extensions, points rust-analyzer at this crate (it isn't at the workspace root), and formats on save. ## Known simplifications from the single-process version - The SSH transport no longer forwards the git subprocess's raw OS-level stderr to the client as extended data — `remote: ...` messages during push/hooks travel through git's own sideband multiplexing *inside* stdout, which is unaffected; only git's own rare fatal-error stderr output would be missed. This also makes SSH consistent with HTTP, which never forwarded that channel either. - The exact git subprocess exit code doesn't cross the proxy hop. SSH now reports exit status 0 on a successful proxy round-trip, 1 on failure — approximating "did the request go through," matching how HTTP has never surfaced git's literal exit code to its client, only logged it server-side.