Small unix daemon to serve git files through nginx.
- Rust 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| deploy | ||
| src | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| README.md | ||
Pages
Serves files straight from Forgejo's bare repositories, at any branch, tag or commit, with no checkouts and no build step. A push is live on the next page load.
URLs
/<repo>/<path>: the default branch/<repo>/@<ref>/<path>: a branch, then a tag, by its exact name, or a full 40-character commit hash- A path ending in
/getsindex.html;/<repo>and a folder without its/redirect to the same with it
How it works
sequenceDiagram
participant B as Browser
participant N as nginx
participant P as pages (Rust, libgit2)
participant G as Bare repository
B->>N: GET /myday/@main/main.js<br/>If-None-Match: "a1b2…"
N->>P: SCGI over /run/pages/pages.sock<br/>REQUEST_METHOD, DOCUMENT_URI, HTTP_IF_NONE_MATCH
P->>P: Split into repo, ref and path
P->>G: refs/heads/main, then main.js in its tree
alt There is no such file
G-->>P: Not found
P-->>N: 404
else The blob id matches If-None-Match
G-->>P: Blob id
P-->>N: 304 Not Modified
else It differs
G-->>P: Blob id and bytes
P-->>N: 200, Content-Type, Content-Length,<br/>ETag: blob id, the bytes
end
N-->>B: The same reply, over TLS
- nginx handles TLS, HTTP/2, gzip and rate limiting. Its event loop holds the browsers' connections, and it passes the daemon only GET and HEAD requests, with the method, the decoded path and
If-None-Match. - pages is a Rust daemon using libgit2. Five OS threads accept from the same Unix socket, each with its own open repository handles, and do the blocking git work. It holds no other state.
Caching
- The ETag is the file's git blob id, so it changes only when the file's bytes do.
- Branches and tags get
Cache-Control: no-cache: the browser asks every time, and an unchanged file costs a 304. - Commit hashes get
Cache-Control: public, max-age=31536000, immutable, so the browser never asks again. - There's no other cache, so no TTL to tune and nothing to clear after a push.
Building
cargo build --release
cargo test
pages <socket> <repositories folder>
It serves <folder>/<repo>.git for each /<repo>/, for example from /var/lib/forgejo/repositories/joexo.
Deploying
deploy/ has the files for one server running Forgejo, nginx and pages:
- nginx-http.conf: port 80, for Let's Encrypt's challenges and a redirect to HTTPS.
- nginx-https.conf: pages on its own name, and Forgejo with the database relays on another.
- app.ini and forgejo.service: Forgejo, with SQLite and HTTPS pushes only.
- pages.service: the daemon, sandboxed.
The daemon reads the repositories through a shared group:
- A
pagesgroup holds both thepagesuser and Forgejo'sgituser. - The repositories folder is
git:pageswith mode2750. The setgid bit makes everything Forgejo creates inside it inherit the group. - Forgejo runs with
UMask=0027, so the group can read what it writes. /var/lib/forgejoisgit:pageswith mode0710, so pages can pass through it but not list it.
Safety
The design removes attack surface rather than guarding it:
- nginx filters first. Only GET and HEAD pass, with no request body and no headers but
If-None-Match. nginx caps the URI's length, decodes it, resolves its dot segments and merges its slashes. - No revision syntax. A ref is looked up as exactly
refs/heads/<ref>, thenrefs/tags/<ref>, or as a full hash. Short hashes,HEAD~1,@{1},FETCH_HEADand the like are all 404s, and libgit2 refuses any name that isn't a valid ref. - One name becomes a path: the repository's, which must be lowercase ASCII (
[a-z0-9._-], not starting with.or-), as Forgejo stores them. - Nothing unchecked is echoed into a header. The only redirects are to a checked repository name and to a folder found in git, percent-encoded.
- Symlinks are never followed. A symlink in a repository is a blob holding its target path, and that text is what's served.
- The daemon is sandboxed by systemd: its own user, a read-only filesystem, no network, Unix sockets only, no capabilities. Its socket is reachable by nginx's group alone.
- Pages get an origin of their own, on a different site from Forgejo, so no cookie passes between them.
Limits
- A file is held in memory once while it's sent. libgit2 can stream only loose objects, and inflates a packed one whole. The daemon writes libgit2's buffer straight to the socket, without a copy of its own.
- A file too big for memory fails gracefully. The unit caps the daemon's address space (
LimitAS=), so an allocation past it fails instead of the kernel killing the daemon, and that request alone gets a 500. A big file only occupies its own thread. - Every repository in the folder is served, private ones included: Forgejo keeps that flag in its database.